diff --git a/.changeset/1580-999-sentinel-milestone-roadmap.md b/.changeset/1580-999-sentinel-milestone-roadmap.md new file mode 100644 index 000000000..2ddf49e5f --- /dev/null +++ b/.changeset/1580-999-sentinel-milestone-roadmap.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 1691 +--- +`milestone complete` and `roadmap analyze` now exclude the Phase 0 / Phase 999 backlog sentinels. A milestone whose only directory-less ROADMAP heading is a backlog sentinel can be completed without `--force`, and `roadmap analyze` no longer counts the sentinel in `phase_count` or routes `next_phase` into it. Completes the `^999` exclusion #1445 added to the progress denominators. diff --git a/.changeset/1733-windows-agent-skills-path-leak.md b/.changeset/1733-windows-agent-skills-path-leak.md new file mode 100644 index 000000000..6c40bb8bc --- /dev/null +++ b/.changeset/1733-windows-agent-skills-path-leak.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 1736 +--- +The `` block emitted by `gsd init` no longer leaks backslash paths into `@`-reference skill paths on Windows. The global skill directory (a native `path.join` result) was interpolated into the generated markdown without POSIX normalization, producing references like `@C:\…\skills\name/SKILL.md`; the reference is now normalized at the emit site so skill references use forward slashes on every platform. diff --git a/.changeset/clever-cats-howl.md b/.changeset/clever-cats-howl.md new file mode 100644 index 000000000..af82bb000 --- /dev/null +++ b/.changeset/clever-cats-howl.md @@ -0,0 +1,7 @@ +--- +type: Changed +pr: 1764 +--- +**Internal: agent install for cursor/windsurf/augment/trae/codebuddy now flows through the descriptor path** — ADR-1235 step 1 routes the trivial-converter runtime group's agents off the inline install() loop onto the descriptor-driven `installRuntimeArtifacts` path, applying the cross-cutting steps uniformly (pre-converter, no workflow-stamp). Agent output is byte-identical for all 16 runtimes (golden-parity asserted, global + local verified); no user-facing change. + + diff --git a/.changeset/daring-otters-dart.md b/.changeset/daring-otters-dart.md new file mode 100644 index 000000000..54f340c3a --- /dev/null +++ b/.changeset/daring-otters-dart.md @@ -0,0 +1,5 @@ +--- +type: Changed +pr: 1757 +--- +**Internal: getDirName is now derived from a documented `runtime.localConfigDir` descriptor field** — each runtime's local content-rewrite directory (e.g. `cursor`→`.cursor`, `copilot`→`.github`) moved from a hand-maintained if-chain into its capability descriptor (ADR-1239 Phase B), so it can no longer drift from the registry. Install output is byte-identical for all 16 runtimes (golden-parity asserted); no user-facing change. diff --git a/.changeset/eager-elks-frolic.md b/.changeset/eager-elks-frolic.md new file mode 100644 index 000000000..de18b72a0 --- /dev/null +++ b/.changeset/eager-elks-frolic.md @@ -0,0 +1,7 @@ +--- +type: Changed +pr: 1759 +--- +**Internal: copyWithPathReplacement converter selection is now data-driven** — the installer's back-compat content-copy path replaced its 13 hardcoded `runtime === 'x'` flag chains with a single per-runtime dispatch table (ADR-1239 Phase B). Install output is byte-identical for all 16 runtimes (golden-parity asserted); no user-facing change. + + diff --git a/.changeset/graceful-badgers-dance.md b/.changeset/graceful-badgers-dance.md new file mode 100644 index 000000000..0f8d58286 --- /dev/null +++ b/.changeset/graceful-badgers-dance.md @@ -0,0 +1,5 @@ +--- +type: Changed +pr: 1719 +--- +**#853 dispatch-flatten is now data-driven (ADR-1239 Phase B)** — whether GSD backgrounds the plan/execute orchestrator is decided from a documentation-sourced `backgroundDispatch` capability per host (via `gsd_run query dispatch-should-flatten`) instead of a hardcoded `runtime === 'codex'` check. **Cursor now backgrounds the orchestrator** (its docs document backgrounded subagent nesting); codex unchanged; all other hosts run inline. Fail-closed to inline on any uncertainty. diff --git a/.changeset/happy-birds-chatter.md b/.changeset/happy-birds-chatter.md new file mode 100644 index 000000000..414063327 --- /dev/null +++ b/.changeset/happy-birds-chatter.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 1755 +--- +**GSD now warns when a stale global CLI (e.g. a retired @gsd-build/sdk canary) shadows your project-local install** — the gsd-tools CLI startup detects when the running binary is outside the project root while a project-local install exists, and prints a remediation warning to stderr (non-blocking). (#1754) diff --git a/.changeset/humble-sloths-jump.md b/.changeset/humble-sloths-jump.md new file mode 100644 index 000000000..88e081286 --- /dev/null +++ b/.changeset/humble-sloths-jump.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 1742 +--- +**Windows install/upgrade/state-write operations no longer fail on transient antivirus/indexer file locks** — the fs.renameSync atomic-publish sites (install state, hooks config, capability ledger/lifecycle, phase/workstream/milestone dirs, roadmap, planning/state locks) now retry EPERM/EBUSY/EACCES via retryRenameSync instead of propagating the transient lock; enforced by the new local/require-fs-op-fallback lint rule (ADR-1703 Phase 6). (#1740) diff --git a/.changeset/kind-lynx-munch.md b/.changeset/kind-lynx-munch.md new file mode 100644 index 000000000..d9190ea41 --- /dev/null +++ b/.changeset/kind-lynx-munch.md @@ -0,0 +1,7 @@ +--- +type: Changed +pr: 1728 +--- +**Internal: derive the non-Claude runtime list from the capability registry** — `NON_CLAUDE_RUNTIMES` is now computed from the capability registry instead of a hand-maintained literal, so it can no longer drift from the per-runtime descriptors. No user-visible behavior change (the list is identical). + + diff --git a/.changeset/lucky-quails-greet.md b/.changeset/lucky-quails-greet.md new file mode 100644 index 000000000..5ef8dc1ea --- /dev/null +++ b/.changeset/lucky-quails-greet.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 1746 +--- +Windows: stop double-quoting $CLAUDE_PROJECT_DIR-anchored managed node hook paths during the #2979 legacy rewrite, which produced "\"$CLAUDE_PROJECT_DIR\"/..." and broke every node managed hook with MODULE_NOT_FOUND (PreToolUse-guard deadlock). diff --git a/.changeset/patient-otters-wave.md b/.changeset/patient-otters-wave.md new file mode 100644 index 000000000..a6800caef --- /dev/null +++ b/.changeset/patient-otters-wave.md @@ -0,0 +1,5 @@ +--- +type: Changed +pr: 1735 +--- +**Internal: extracted the runtime-artifact install engine from `bin/install.js`** — `installRuntimeArtifacts`/`uninstallRuntimeArtifacts`/`installOpencodeFamilySkills` and their helpers now live in a dedicated `gsd-core/bin/lib/install-engine.cjs` module (ADR-1239 Phase B), so adapters can import the install pipeline instead of reaching into the 12k-line installer. Install output is byte-identical for all 16 runtimes (golden-parity asserted); no user-facing behaviour change. diff --git a/.changeset/tidy-tunas-click.md b/.changeset/tidy-tunas-click.md new file mode 100644 index 000000000..68724cb7e --- /dev/null +++ b/.changeset/tidy-tunas-click.md @@ -0,0 +1,5 @@ +--- +type: Security +pr: 1725 +--- +**Installer writes are now confined to the declared config home** — the workflow/skill emit path (`copyWithPathReplacement`) and the Codex config writer (`installCodexConfig`) now reject any destination that escapes the install root: crafted or absolute paths, path-separator agent names, and pre-existing symlinks are refused before any delete or write. Fail-closed: an install write with no declared root is rejected rather than written unconfined. diff --git a/.changeset/vivid-seals-purr.md b/.changeset/vivid-seals-purr.md new file mode 100644 index 000000000..63c2649d7 --- /dev/null +++ b/.changeset/vivid-seals-purr.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 1690 +--- +**Host-Integration Interface (ADR-1239 Phase A)** — a versioned, negotiated capability contract (`runtime.hostIntegration`) over the six host-integration points (command, dispatch, model, hooks, state, artifact). Adds an in-process `negotiateHostCapabilities` handshake that fail-closes on undeclared/unknown/`undocumented` values (`effective ⊆ host-declared ∩ engine-known`), a typed degradation ladder, host-capability profiles, and a documentation-sourced per-CLI capability matrix for all 16 runtimes. Interface-definition only — no change to install behaviour. diff --git a/.changeset/zesty-rams-march.md b/.changeset/zesty-rams-march.md new file mode 100644 index 000000000..1b66f6a2e --- /dev/null +++ b/.changeset/zesty-rams-march.md @@ -0,0 +1,5 @@ +--- +type: Security +pr: 1706 +--- +**Install write-confinement (ADR-1239 Phase B)** — the installer now rejects any runtime-descriptor `destSubpath` that would write or delete outside the user's config home (path traversal, the config root itself, NUL bytes) and refuses to follow a pre-existing symlink that escapes it. Hardening only; no change to legitimate installs. diff --git a/.gitignore b/.gitignore index 12009ded1..79a512a4a 100644 --- a/.gitignore +++ b/.gitignore @@ -67,6 +67,9 @@ build/ # by `npm run build:lib`). Source of truth is src/; these are emitted, never edited. # Published via prepublishOnly; built before test via pretest. Grows as modules migrate. /tsconfig.build.tsbuildinfo +/gsd-core/bin/lib/host-integration.cjs +/gsd-core/bin/lib/install-engine.cjs +/gsd-core/bin/lib/cli-skew-check.cjs /gsd-core/bin/lib/capability-loader.cjs /gsd-core/bin/lib/capability-source.cjs /gsd-core/bin/lib/capability-ledger.cjs diff --git a/CONTEXT.md b/CONTEXT.md index f64224287..a0297a4ea 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -118,6 +118,12 @@ Module owning bounded, never-throw git repository introspection — the single s ### Runtime Name Policy Module Module owning runtime identity normalization at runtime-selection seams. Canonicalizes alias signals from env/config (`GSD_RUNTIME`, `.planning/config.json:runtime`) to supported runtime IDs so output emitters and query runtime gates stay consistent across naming variants (for example `codex-app`/`codex-cli` -> `codex`). Sources: `gsd-core/bin/lib/runtime-name-policy.cjs`, alias manifest `gsd-core/bin/shared/runtime-aliases.manifest.json`. +### Host-Integration Interface +Pure, additive, no-I/O Module owning the versioned, negotiated contract over the six host-integration interface points (command, dispatch, model, hooks, state, artifact) — ADR-1239 Phase A. Extends the ADR-1016 runtime descriptor with eight closed-vocabulary axes carried under `capability.json` `runtime.hostIntegration`: `embeddingMode` (`imperative|declarative`), `commandSurface` (`slash-file|slash-programmatic|slash-toml|palette|prose-only`), `dispatch` (`{namedDispatch,nested,maxDepth,background,backgroundDispatch,subagentToolkit}`), `modelMode` (`active|passive`), `hookBus` (`host|engine|none`), `stateIO` (`filesystem|sandboxed-storage|session-log-append`), `transport` (`mcp|native-extension`), `runtime` (`node|bun|sandboxed-web|python|go|rust|electron|other`). Interface: `negotiateHostCapabilities(host, engine?) → { protocolVersion, effective, points, warnings }` enforcing the trust-boundary invariant `effective ⊆ host-declared ∩ engine-known` (never augment with an undeclared or unknown/future-`protocolVersion` value — fail-closed via the most-restrictive-known `SAFE_DEFAULTS`); `degradationFor(point, axes) → { level, fallback }` (a pure Full/Degraded/Absent ladder table, never throws); `profileOf(axes) → 'programmatic-cli'|'declarative-cli'|'ide'|null`; plus `PROTOCOL_VERSION` (integer, starts at 1 — distinct from the package `version`/`engines.gsd` semver), `HOST_INTEGRATION_AXES` (the frozen closed vocabulary, single source of truth), `PROFILE_BASELINES`, and `shouldFlattenDispatch(dispatch) → boolean` (ADR-1239 Phase B / #1708 — graduates the #853 rule: returns `true` = run the orchestrator inline UNLESS the host is documented to background a nesting-capable orchestrator (`background === true && backgroundDispatch === true`); fail-closed to inline; exposed to the plan/execute workflows via the `gsd_run query dispatch-should-flatten --raw` CLI, which replaced the former scattered `RUNTIME === 'codex'` prose check). The runtime-descriptor validator (`gsd-core/bin/lib/capability-validator.cjs` `validateRuntimeBody`) mirrors the closed vocabulary inline (exported as `_HOST_INTEGRATION_VOCAB`) and is kept in lock-step by the parity guard `tests/host-integration-validator-parity.test.cjs`. Orthogonal axes (resolved explicitly per ADR-1239 Phase A): `commandStyle` (GSD emission style, retained) vs `commandSurface` (host surface type); `hookEvents` dialect vs `hookBus` ownership (a host with `hooksSurface:none` may still be `hookBus:host` — e.g. opencode); `runtimeCompat` (feature→host) vs these negotiated runtime→engine axes. Phase A defined the interface; Phase B (#1679) wires it incrementally — `destSubpath` write-confinement (#1704) and the typed documentation-sourced #853 dispatch-flatten (#1708, the first consumer of a negotiated `dispatch` axis); adapters/MCP/host-bindings remain Phases C–E. Source of truth: `gsd-core/bin/lib/host-integration.cjs` (generated from `src/host-integration.cts`). See ADR-1239 and ADR-1016. + +### Install Engine Module +Module owning the layout-driven runtime-artifact install pipeline — `installRuntimeArtifacts`, `uninstallRuntimeArtifacts`, `installOpencodeFamilySkills`, and their cluster helpers (`_copyStaged`, `_snapshotDir`/`_restoreDir`, legacy-migration + GSD-entry pruning, user-artifact preserve/restore). Extracted from the 12k-line `bin/install.js` (ADR-1239 Phase B, #1679) so adapters import the engine instead of reaching into the installer. Commit-attribution resolution stays in `bin/install.js` and is injected via a `resolveAttribution` parameter (the engine takes no config I/O). Source: `src/install-engine.cts` -> `gsd-core/bin/lib/install-engine.cjs`. + ### Installer Migration Authoring Guard Module Module owning validation for Installer Migration Module records and planned actions. It enforces migration metadata, explicit install scopes, ownership evidence for destructive/config actions, and runtime contract citations for runtime config rewrites before a migration can enter planning or apply. @@ -161,7 +167,7 @@ Module owning the per-runtime mapping from artifact kind to filesystem placement Sibling Module to Runtime Artifact Layout Module. Owns projection from canonical Claude-authored command/agent/skill markdown into runtime-specific artifact bodies, including converter selection, frontmatter/body normalization, runtime path rewrites, and staged artifact generation. Runtime Artifact Layout remains responsible for filesystem placement (`kind`, destination subpath, prefix, nesting); Runtime Artifact Conversion owns the content Implementation behind that placement seam so install, uninstall/surface parity, and future plugin/package projections stop reaching back through `bin/install.js` for converter functions or `GSD_TEST_MODE`-guarded installer exports. Chosen direction: sibling Module, not an expanded Layout Module, to preserve ADR-3660's narrow placement responsibility while deepening artifact content locality. First slice: relocate only the layout-reached conversion family (`convertClaudeCommandTo*Skill`, converted command-file emitters, `buildKimiAgentArtifacts`) plus the minimal helper closure they need; do not leave helper dependencies in `bin/install.js` because that would preserve the same shallow seam under a new filename. Installer integration decision: `bin/install.js` imports the conversion Module at top level and re-exports the moved names for compatibility; the conversion Module must not import `bin/install.js` or Runtime Artifact Layout, so the dependency direction becomes installer/layout Adapters -> conversion Module, never conversion -> installer. First-slice Interface decision: export the existing compatibility names only; do not introduce a grouped `convertRuntimeArtifact` Interface until after relocation proves byte-for-byte behavior. SHIPPED (ADR-1508): the converter family relocated in #1510 Phase 1 (`getDirName`→runtime-name-policy, `processAttribution` here); #1511 Phase 2 moved the content-rewrite engine here in full — `_applyRuntimeRewrites` (per-runtime switch, injected attribution), the staged-content walkers `applyRuntimeContentRewritesInPlace`/`applyRuntimeContentRewritesForCommandsInPlace`, `computePathPrefix` (private; `_computePathPrefix` for tests), and the deep public seam `rewriteStagedSkillBodies`/`rewriteStagedCommandBodies({runtime,configDir,scope,homedir?,platform?,resolveAttribution?})`. `bin/install.js` binds these back (single owner, exports preserved); `getCommitAttribution` stays in `bin/install.js` (impure install-time config I/O) and is injected. The `getInstallExports` relay in Runtime Artifact Layout Module was deleted; the dependency direction installer/layout → conversion (never upward) is now enforced. Exception: opencode and kilo path-prefix rewriting is a deliberate `bin/install.js`-owned pre-conversion step (`applyOpencodeFamilyPathPrefix`) per #784, not a violation of the single-owner rule. Source: `gsd-core/bin/lib/runtime-artifact-conversion.cjs` (generated from `src/runtime-artifact-conversion.cts`). Also exports `resolveVersionFrom(libDir)` — a lazy, defensive GSD-version resolver (installed-tree `gsd-core/VERSION` first, then the source/npm `package.json` three dirs up, both validated against the repo's shared semver-prefix shape, degrading to `''` on failure) that replaced a module-load-time `require('../../../package.json')` which crashed on runtimes whose root carries no `package.json` (e.g. Codex) (#1383). ### Runtime Artifact Install Plan Module -Module owning install-time staging and content-rewrite selection for a pre-resolved Runtime Artifact Layout. Interface: `createRuntimeArtifactInstallPlan({ layout, resolvedProfile, homedir?, platform?, resolveAttribution?, deps? }) -> { ok:true, plan:{ items, cleanupDirs } } | { ok:false, kind:'stage_failed'|'rewrite_failed', message, cleanupDirs, failedKind? }`. It iterates `layout.kinds` in order, calls each kind's `stage(resolvedProfile)`, delegates `commands` to Runtime Artifact Conversion `rewriteStagedCommandBodies`, delegates `skills` and `kimi-agents` to `rewriteStagedSkillBodies`, leaves non-rewritten kinds unchanged, and projects copy items as `{ kind, sourceDir, destDir }`. It deliberately does not prune, copy, run legacy migrations, print output, or execute cleanup; those remain Installer Module adapter responsibilities until later slices wire the plan into `bin/install.js`. Source: `gsd-core/bin/lib/runtime-artifact-install-plan.cjs` (generated from `src/runtime-artifact-install-plan.cts`). See Runtime Artifact Layout Module and Runtime Artifact Conversion Module. +Module owning install-time staging and content-rewrite selection for a pre-resolved Runtime Artifact Layout. Interface: `createRuntimeArtifactInstallPlan({ layout, resolvedProfile, homedir?, platform?, resolveAttribution?, deps? }) -> { ok:true, plan:{ items, cleanupDirs } } | { ok:false, kind:'stage_failed'|'rewrite_failed', message, cleanupDirs, failedKind? }`. It iterates `layout.kinds` in order, calls each kind's `stage(resolvedProfile)`, delegates `commands` to Runtime Artifact Conversion `rewriteStagedCommandBodies`, delegates `skills` and `kimi-agents` to `rewriteStagedSkillBodies`, leaves non-rewritten kinds unchanged, and projects copy items as `{ kind, sourceDir, destDir }`. It deliberately does not prune, copy, run legacy migrations, print output, or execute cleanup; those remain Installer Module adapter responsibilities until later slices wire the plan into `bin/install.js`. **Write-confinement (ADR-1239 Phase B / #1679):** the exported pure `assertDestWithinConfigHome(configDir, destSubpath) -> resolvedDest` is the security gate — every kind's `destDir` is computed through it on both the install and uninstall plan paths, so a `destSubpath` that escapes `configHome` (`../../etc`, a NUL byte, etc.) is rejected at plan-build time with a clear error; `surface.cjs:applySurface` and `bin/install.js:installOpencodeFamilySkills` route their joins through the same helper, and `_copyStaged` carries a defense-in-depth containment check. This is security-load-bearing for the Phase C third-party-descriptor loader (which is where an untrusted `destSubpath` could arrive). Source: `gsd-core/bin/lib/runtime-artifact-install-plan.cjs` (generated from `src/runtime-artifact-install-plan.cts`). See Runtime Artifact Layout Module and Runtime Artifact Conversion Module. ### Command Roster Module Tiny read-only helper Module owning discovery of canonical `commands/gsd/*.md` command stems for artifact conversion and runtime projection. It is a sibling dependency of Runtime Artifact Conversion Module, not part of conversion itself: conversion consumes a roster to safely rewrite `gsd:` / `/gsd-` references, while roster discovery owns filesystem/catalog knowledge. First slice: extract existing `readGsdCommandNames` behavior behind this Module instead of moving it into Runtime Artifact Conversion Module or keeping it as installer-owned state. @@ -358,6 +364,31 @@ The prompt-level data/instruction isolation seam for untrusted web/document ingr --- +## Probe family — spec-completeness probes (machine-oriented predicates) + +> Glossary prose for these modules lives above (Probe Core / Edge Probe / Prohibition Probe / Verification Tier / Verification substrate). These are the greppable one-line predicates ADR-550's Consequences promised alongside the glossary. Research-derived numbers (N17/N18 rates) are deliberately kept out of this machine-canon and live hedged in `docs/design/verifier-reach.md`. (That design note and `docs/adr/1606` are co-delivered sibling PRs of epic #1605; predicate refs to them below resolve once the batch lands.) + +`PROBE.principle=verifier-reach-equals-spec-reach (a goal-backward verifier only checks assertions that exist; probes make omitted assertions exist before code) — ADR-857 verification-substrate boundary; docs/design/verifier-reach.md` +`PROBE.family=edge-probe(shape-axis)+prohibition-probe(must-NOT-axis), shared probe-core, run as spec-phase soft gates (ADR-550 D7)` +`PROBE.protocol=recall(adversarial over-generate)->precision(drop routine-engineering); dismissals require a non-empty reason` +`PROBE.core.seam=analyzeCoverage(items,resolutions?,validators) ingests ALREADY-proposed items; does NOT assume deterministic propose (ADR-550 D7b)` +`PROBE.item.axes=status{resolved|dismissed|unresolved} x verification{|null} — orthogonal; the lifecycle enum carries no verification fact (ADR-550 D7a)` +`PROBE.edge.verification=explicit|backstop` +`PROBE.prohib.verification=test|judgment` +`PROBE.ci.surface=the contract (parse/validate, projection round-trip, fail-closed guards), NEVER the LLM judgment (ADR-550 D5)` +`PROHIB.recall=LLM-prose; no compiled prohibition-probe recall engine (only the schema/projection layer is code, ADR-550 D7b)` +`PROHIB.canon-referral=OWASP/GDPR/fairness-canon are REFERRED to /gsd:secure-phase+eslint, never minted as prohibitions (ADR-550 D6)` +`PROHIB.enforce.green-rule=passed iff provenFailFirst===true && run.passed===true (runProhibitionEnforcement); every miss/fail/un-provable HARD-GATES both modes via dispositionForProhibition's fail-closed default` +`PROHIB.enforce.kinds=node-test (non-vacuous red via isNonVacuousNodeTestRed; pass-side vacuity via isNonVacuousNodeTestPass) | lint-rule (eslint --format json filtered by ruleId)` +`PROHIB.enforce.failfirst=MACHINE-PROVEN against an author-supplied violation fixture (#1279); caller failFirst attestation DEMOTED to a non-authoritative hint (FF-08)` +`PROHIB.enforce.causation=opt-in clean-fixture control proves the red is content-caused not env-var-set (#1346); absent=documented residual` +`PROHIB.descriptor.shape=5 FLAT scalars (check_kind,check_target,check_rule,check_violation_fixture,check_clean_fixture) — NEVER a nested check:{} (parseMustHavesBlock is a flat parser, src/frontmatter.cts)` +`PROHIB.rail=core verify rail, non-toggleable (ADR-857 verification-substrate boundary / decision #6); the verifier<->predicate contract is NOT an off-by-default capability` +`PROHIB.judgment-tier=never-silent / never-hard-halt soft gate; autonomous emits "unverified-prohibition — human review recommended" (exogenous grading, ADR-550 D4)` +`PROHIB.enforce.adr=docs/adr/1606 (verify-time enforcement seam) + docs/adr/550 (spec-phase contract)` + +--- + ## Test rules and lint `RULESET.TESTS.no-source-grep=scripts/lint-no-source-grep.cjs rejects readFileSync source + .includes()/.match()/.startsWith() on the bound var; CI hard-fail` @@ -680,8 +711,8 @@ The prompt-level data/instruction isolation seam for untrusted web/document ingr `DEFECT.WINDOWS-FS-OPS.symptom=fs.renameSync / fs.copyFileSync hits EPERM/EBUSY on Windows when antivirus or another process holds a transient handle on the target` `DEFECT.WINDOWS-FS-OPS.examples=c47c2c5d build-hooks rename → copy fallback, d2412271 install Windows persistent SDK shim` -`DEFECT.WINDOWS-FS-OPS.detect=any rename/copy in build/install path without try/catch fallback` -`DEFECT.WINDOWS-FS-OPS.fix-forward=catch EPERM/EBUSY/EACCES, fall back to copy + unlink with retry, surface degraded-mode message; never silently swallow` +`DEFECT.WINDOWS-FS-OPS.detect=ADR-1703 Phase 6: enforced by local/require-fs-op-fallback (AST ESLint rule, error) over src/**/*.cts + bin/install.js + scripts/build-hooks.js — flags an unguarded fs.rename/fs.renameSync (the atomic-publish primitive named in .symptom) that lacks a transient-errno retry or a Windows platform guard; a catch that silently swallows or cleans-up-and-rethrows without an errno check does NOT satisfy the .fix-forward clause. copyFile/unlink are the fallback primitives (out of scope); delegated retry helpers (retryRenameSync from shell-command-projection) are the recognized compliant shape` +`DEFECT.WINDOWS-FS-OPS.fix-forward=catch EPERM/EBUSY/EACCES, fall back to copy + unlink with retry, surface degraded-mode message; never silently swallow; the canonical production cure is retryRenameSync (shell-command-projection.cjs) or a bounded RENAME_RETRY_ERRNOS = new Set(['EPERM','EBUSY','EACCES']) loop` `DEFECT.UNBOUNDED-SUBPROCESS.symptom=git/npm subprocess shelled out without timeout; CLI hangs indefinitely on stuck remote, large repo, or missing network` `DEFECT.UNBOUNDED-SUBPROCESS.examples=a33cbe72 worktree fix bound git subprocesses with timeout` @@ -720,36 +751,36 @@ The prompt-level data/instruction isolation seam for untrusted web/document ingr `DEFECT.FRONTMATTER-SCALAR-BROAD-GREP.examples=#586/PR #650 ship.md verification gate — grep "^status:" also matched body status: lines, yielding passed+gaps_found+human_needed instead of passed and blocking a passed phase; the same broad-grep still lives in execute-phase.md (consolidation tracked by #651)` `DEFECT.FRONTMATTER-SCALAR-BROAD-GREP.detect=grep "^:" on a *.md whose result is compared to exact tokens, with no frontmatter scoping and no -m1; one body line beginning : is enough to break it` `DEFECT.FRONTMATTER-SCALAR-BROAD-GREP.fix-forward=scope to the leading frontmatter block and take the first match: sed -n '/^---$/,/^---$/p' "$f" | grep -m1 "^:" | cut -d: -f2 | tr -d ' '; fix every parallel copy in the same change or consolidate behind one queryable seam (#651)` -`DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.symptom=a test that parses a workflow bash block out of a *.md and runs it via execFileSync('bash',...) breaks on Windows two ways: the fence regex uses a literal \n after the bash fence that will not match CRLF and trips windows-test-parity-guard (fenceRegexLiteralNewline); and git-bash exists so a bash-presence probe is true, but an os.tmpdir() Windows path (C:\...) is un-globbable in bash so the pipeline returns empty and assertions fail` +`DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.symptom=a test that parses a workflow bash block out of a *.md and runs it via execFileSync('bash',...) breaks on Windows two ways: the fence regex uses a literal \n after the bash fence that will not match CRLF and is flagged by local/no-crlf-fragile-split (the windows-test-parity-guard ratchet it formerly tripped was deleted in ADR-1703 Phase 4 #1726); and git-bash exists so a bash-presence probe is true, but an os.tmpdir() Windows path (C:\...) is un-globbable in bash so the pipeline returns empty and assertions fail` `DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.examples=#586/PR #650 tests/ship-586-verification-routing.test.cjs — the fence \n offender failed ubuntu-24/macos/coverage, then the Windows tmpdir-path glob failed full test (windows-latest,22) at fail 3; both were invisible to file-scoped gsd-test-both runs because the parity guard is only scanned by the full suite` -`DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.detect=test does readFileSync(md).match for a bash fence with literal \n, OR execFileSync('bash',...) gated only on a bash-presence probe; also verifying a new test with a file-scoped run instead of the full suite hides repo-wide static guards` +`DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.detect=test does readFileSync(md).match for a bash fence with literal \n, OR execFileSync('bash',...) gated only on a bash-presence probe; also verifying a new test with a file-scoped run instead of the full suite hides repo-wide static guards; now enforced at write-time + CI by local/no-crlf-fragile-split (CRLF fence/frontmatter regex + readFileSync split-on-\n) and local/no-unguarded-nonportable-exec (bash+chmod), eslint, ADR-1703` `DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.fix-forward=match the fence with \r?\n and normalize the captured block to LF; gate pipeline execution on process.platform !== 'win32' && hasBash since the extraction LOGIC is platform-independent and POSIX coverage suffices; run the full suite (or the parity/lint guards) before push when adding a test file` `DEFECT.WINDOWS-TEST-PORTABILITY.symptom=local gsd-test runs Mac+Linux only (no Windows host); Windows-only test failures (chmod exec-bit not honored for PATH-executing extension-less scripts in Git Bash msys2; / vs \ path-separator in assertions; Git Bash msys2 shell semantics) surface ONLY in CI test (windows-latest,*) / full test (windows-latest,*) lanes, never locally` `DEFECT.WINDOWS-TEST-PORTABILITY.examples=PR #1084 (chmod 0o755 + bare-command execution failed on windows lane); PR #1692 tests/stale-bake-guard.test.cjs resolveAgentDir assertions hardcoded '/H/.config/opencode/agent' forward-slash literals against a path.join return — passed macOS/linux/ubuntu CI (incl. gsd-test docker mirror), failed windows-latest,24 + full test windows-latest,22 shard 2/3; test files that assert path.join result without normalizing to forward slashes` -`DEFECT.WINDOWS-TEST-PORTABILITY.detect=npm run lint:windows-test-portability (tripwire: flags tests combining chmod exec-bit with sh/bash -c and no platform guard); watch CI windows matrix green before declaring a PR done` -`DEFECT.WINDOWS-TEST-PORTABILITY.fix-forward=gate platform-specific execution with if (process.platform !== 'win32'); normalize path expectations to forward slashes with .replace(/\\/g, '/'); invoke scripts via explicit interpreter (sh ) rather than relying on exec-bit; annotate // windows-portability-ok: when a bypass is intentional` -`DEFECT.WINDOWS-TEST-PORTABILITY.prevention=run lint:ci before opening a PR; treat the CI windows lane as the only true Windows signal — gsd-test (Mac/Linux only) cannot substitute for it` +`DEFECT.WINDOWS-TEST-PORTABILITY.detect=npm run lint (eslint) runs the local/* AST portability rules (ADR-1703): local/no-unguarded-nonportable-exec flags a test that chmods an exec bit AND runs it via sh/bash -c without a process.platform !== 'win32' guard (the retired scripts/lint-windows-test-portability.cjs tripwire, migrated to AST in #1720); local/no-path-literal-in-assert + local/no-posix-mode-bit-assert cover the assertion shapes; local/no-crlf-fragile-split (CRLF file-content split/regex), local/no-hardcoded-tmp (/tmp literal → os.tmpdir()), local/no-bare-npm-exec (npm needs shell:true on Windows) and local/require-userprofile-with-home (set USERPROFILE alongside HOME) replace the deleted windows-test-parity-guard ratchet (#1726); all are platform-guard-aware with zero opt-out (tests/portability-rule-disable-ban.test.cjs); watch CI windows matrix green before declaring a PR done` +`DEFECT.WINDOWS-TEST-PORTABILITY.fix-forward=gate platform-specific execution with if (process.platform !== 'win32'); normalize path expectations to forward slashes with .replace(/\\/g, '/'); invoke scripts via explicit interpreter (sh ) rather than relying on exec-bit; there is NO opt-out for the local/* portability rules — structure platform-specific code behind a recognized process.platform !== 'win32' guard (ADR-1703 zero escape hatch)` +`DEFECT.WINDOWS-TEST-PORTABILITY.prevention=run npm run lint (the local/* AST portability rules, ADR-1703) before opening a PR; treat the CI windows lane as the only true Windows signal — gsd-test (Mac/Linux only) cannot substitute for it` `DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.symptom=a test writes a file with a POSIX mode (fs.writeFileSync(p, data, {mode: 0o644}) or fs.chmodSync) then asserts fs.statSync(p).mode & 0o777 === ; passes on macOS/Linux/ubuntu CI, FAILS on the windows-latest CI lane — Windows fs does NOT honor POSIX write modes, Node reports the mode derived from the DOS readonly attribute (0o666 for writable / 0o444 for readonly), never the requested 0o644/0o755` `DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.examples=#1634/PR #1638 tests/capability-lifecycle.test.cjs "a .cjs hook command is node-prefixed so it runs without the executable bit" failed windows-latest,24 on "precondition: file staged without +x" (expected 420/0o644, got 438/0o666); the node-prefix behavioral assertion was correct — only the mode-bit precondition was the POSIX-only fact` -`DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.detect=grep tests for \`.mode & 0o777\` / \`.mode) === 0o\` / \`writeFileSync(...{ mode: 0o\` / \`chmodSync\` paired with a strict-equality assertion on the resulting mode; any such assertion is a POSIX-only fact that will diverge on Windows (write reads back as 0o666)` +`DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.detect=grep tests for \`.mode & 0o777\` / \`.mode) === 0o\` / \`writeFileSync(...{ mode: 0o\` / \`chmodSync\` paired with a strict-equality assertion on the resulting mode; any such assertion is a POSIX-only fact that will diverge on Windows (write reads back as 0o666); NOW mechanically enforced by the AST ESLint rule local/no-posix-mode-bit-assert (eslint-rules/no-posix-mode-bit-assert.cjs, ADR-1703 Phase 2 #1711) — flags a .mode-vs-octal-literal equality assertion unless control-dependent on a process.platform !== 'win32' guard (eslint-rules/lib/platform-guard.cjs); zero opt-outs (tests/portability-rule-disable-ban.test.cjs)` `DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.fix-forward=gate the mode-bit precondition on if (process.platform !== 'win32') — the executable-bit/mode is a POSIX concept meaningless on Windows; KEEP the platform-independent behavioral assertion (the actual behavior under test) running on every OS; do NOT delete the precondition, scope it to POSIX` -`DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.prevention=ref DEFECT.WINDOWS-TEST-PORTABILITY — gsd-test is Mac/Linux only (no Windows host), only the CI windows-latest lane catches this; run npm run lint:ci (lint-windows-test-portability) before push; prefer asserting the BEHAVIOR (command shape, runnability) over the filesystem mode bit` +`DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.prevention=ref DEFECT.WINDOWS-TEST-PORTABILITY — gsd-test is Mac/Linux only (no Windows host), only the CI windows-latest lane catches this; enforced at write-time + CI by the AST ESLint rule local/no-posix-mode-bit-assert (eslint, error; ADR-1703 Phase 2 #1711); run npm run lint before push; prefer asserting the BEHAVIOR (command shape, runnability) over the filesystem mode bit` `DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.symptom=path.join() result on Windows (backslashes) substituted verbatim into markdown body (@-references, workflow files, generated docs); content gains mixed separators; cross-platform substring assertions fail on windows-latest CI lane only; macOS/Linux CI green so defect ships undetected` `DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.examples=PR #1622 computePathPrefix returned ${resolvedTarget}/ verbatim — rewrites of @~/.claude/gsd-core/commands/gsd/X.md wrote @C:\...\gsd-ial-windsurf-XXX\gsd-core/commands/gsd/help.md (trailing forward slashes from the original literal survived, prefix backslashes did not); tests/install-runtime-artifacts.test.cjs:318 + tests/install.test.cjs:1323 failed on windows-latest only` -`DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.detect=any function returning a filesystem path that flows into markdown/text body substitution; grep for path.join/raw resolvedTarget/${configDir}/ in code paths writing workflow .md, agent .md, or generated docs; smoke pattern is ${resolvedTarget}/ or ${configDir}/... templates that bypass normalization` +`DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.detect=any function returning a filesystem path that flows into markdown/text body substitution; grep for path.join/raw resolvedTarget/${configDir}/ in code paths writing workflow .md, agent .md, or generated docs; smoke pattern is ${resolvedTarget}/ or ${configDir}/... templates that bypass normalization; NOW enforced at write-time + CI by local/normalize-path-in-content (eslint, error, src/**/*.cts; ADR-1703 Phase 5 #1733) — flags a path-returning fn result (path.basename excluded — returns a separator-less filename) interpolated DIRECTLY into @-reference content (shape a: @~/, @$, @/) or into a template immediately followed by a /…\.md or /…\.json quasi (shape b); INDIRECT data-flow (path stored in a variable/object field then interpolated, e.g. ${entry.ref}) is NOT detected by the rule — normalize at the assignment source or at the emit site; one known indirect leak (src/init.cts cmdAgentSkills entry.ref) fixed in PR #1733 by normalizing at emit; zero opt-out (the out-of-band disable-ban scans src/**/*.cts too)` `DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.fix-forward=normalize at the SOURCE not the test: posixTarget=String(resolvedTarget).replace(/\\/g,'/'), posixHome=homeDir?String(homeDir).replace(/\\/g,'/'):homeDir; markdown body is POSIX-only; .replace(/\\/g,'/') is idempotent on POSIX (no backslashes present) so safe to apply unconditionally; isWindowsHost arg is a no-op tripwire (enh-1511) — do NOT branch on it, normalize always` -`DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.prevention=RULESET.CONTENT-PATH-NORMALIZATION; tests are downstream signal, never the fix; ref DEFECT.WINDOWS-TEST-PORTABILITY for test-side parity (normalize expected substrings too: ${configDir}/foo.replace(/\\/g,'/'))` +`DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.prevention=enforced by local/normalize-path-in-content (eslint, error; ADR-1703 Phase 5 #1733) per RULESET.CONTENT-PATH-NORMALIZATION; tests are downstream signal, never the fix; ref DEFECT.WINDOWS-TEST-PORTABILITY for test-side parity (normalize expected substrings too: ${configDir}/foo.replace(/\\/g,'/'))` -`RULESET.CONTENT-PATH-NORMALIZATION=filesystem paths substituted into markdown body text (@-references, workflow .md, agent .md, generated docs, command bodies) MUST be normalized to POSIX forward slashes via .replace(/\\/g,'/') at the production source BEFORE substitution; never push normalization to tests; cross-platform content is POSIX-only; applies to: computePathPrefix output, install-path rewrites, generated shim paths emitted into .md bodies; idempotent on POSIX so unconditional` +`RULESET.CONTENT-PATH-NORMALIZATION=filesystem paths substituted into markdown body text (@-references, workflow .md, agent .md, generated docs, command bodies) MUST be normalized to POSIX forward slashes via .replace(/\\/g,'/') at the production source BEFORE substitution; never push normalization to tests; cross-platform content is POSIX-only; applies to: computePathPrefix output, install-path rewrites, generated shim paths emitted into .md bodies; idempotent on POSIX so unconditional; mechanically enforced by local/normalize-path-in-content (eslint, src/**/*.cts; #1733)` `DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT.symptom=an assertion compares the return value of a path-returning function (resolveAgentDir, path.join, path.resolve, getPathX, computePathPrefix, etc.) to a HARDCODED forward-slash string literal like '/H/.config/opencode/agent' or 'C:/Users/...' — passes on POSIX (macOS/linux/ubuntu CI incl. gsd-test docker mirror, where path.join emits forward slashes so literal == actual), FAILS on windows-latest CI lane where path.join emits backslashes so literal != actual` `DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT.examples=PR #1692 tests/stale-bake-guard.test.cjs resolveAgentDir suite: assert.equal(resolveAgentDir('opencode',{homedir:()=>'/H'}), '/H/.config/opencode/agent') — green on macOS+ubuntu (docker gate PASS 21101/21101), red on test (windows-latest,24) + full test (windows-latest,22, shard 2/3); same root cause as DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT but on the TEST side against a function return, not the production-markdown side` -`DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT.detect=any assert*/expect call whose ACTUAL operand is a call to a path-returning fn (path.join, path.resolve, resolveAgentDir, getPathX, computePathPrefix, os.homedir(), path.dirname/basename) AND whose EXPECTED operand is a string literal containing '/' that does NOT first flow through .replace(/\\/g,'/'); the literal-vs-fnCall shape is the tripwire — assert.equal(pathFn(...), '/hardcoded/posix/path') is the violation; assert.equal(String(pathFn(...)).replace(/\\/g,'/'), '/hardcoded/posix/path') is the compliant form` +`DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT.detect=any assert*/expect call whose ACTUAL operand is a call to a path-returning fn (path.join, path.resolve, resolveAgentDir, getPathX, computePathPrefix, os.homedir(), path.dirname/basename) AND whose EXPECTED operand is a string literal containing '/' that does NOT first flow through .replace(/\\/g,'/'); the literal-vs-fnCall shape is the tripwire — assert.equal(pathFn(...), '/hardcoded/posix/path') is the violation; assert.equal(String(pathFn(...)).replace(/\\/g,'/'), '/hardcoded/posix/path') is the compliant form; NOW mechanically enforced by the AST ESLint rule local/no-path-literal-in-assert (eslint-rules/no-path-literal-in-assert.cjs, ADR-1703 Phase 1 #1707) — platform-guard-aware (won't flag an assertion control-dependent on a process.platform !== 'win32' guard; eslint-rules/lib/platform-guard.cjs), fn list single-sourced as eslint-rules/lib/portability-vocab.cjs PATH_RETURNING_FNS (drift-guarded vs src/runtime-homes.cts)` `DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT.fix-forward=normalize the ACTUAL value to POSIX before comparing: assert.equal(String(pathFn(...)).replace(/\\/g,'/'), '/posix/literal'). Do NOT instead path.join the expected value to match the platform separator — that passes on every platform but masks a malformed backslash-on-POSIX return (both sides wrong together). The .replace is idempotent on POSIX so it is safe unconditionally. For values that are conceptually never paths (null/undefined/numbers), no normalization needed.` -`DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT.prevention=run npm run lint:ci (lint-windows-test-portability) before push — enhancement TBD to extend that lint to flag the literal-vs-pathFn assertion shape mechanically; treat the CI windows-latest lane as the only true Windows signal — gsd-test (Mac/Linux only) cannot substitute; ref umbrella DEFECT.WINDOWS-TEST-PORTABILITY and production-side analogue DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT` +`DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT.prevention=enforced at write-time (editor) and in CI by the AST ESLint rule local/no-path-literal-in-assert (error, scoped to tests/**/*.test.cjs in eslint.config.mjs; ADR-1703 Phase 1 #1707); inline suppression is banned out-of-band by tests/portability-rule-disable-ban.test.cjs (zero escape hatches — structure platform-specific code behind a recognized process.platform guard, never opt out); run npm run lint before push; treat the CI windows-latest lane as the only true Windows signal — gsd-test (Mac/Linux only) cannot substitute; ref umbrella DEFECT.WINDOWS-TEST-PORTABILITY and production-side analogue DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT` `DEFECT.PROMPT-INJECTION-SCAN-COLLISION-WITH-TESTS.symptom=scripts/prompt-injection-scan.sh flags a NEW test file as a finding because the test contains real injection payloads as fixtures (strings that match one of the scanner's PATTERNS — see scripts/prompt-injection-scan.sh lines 18-64) to prove the validator under test rejects them; scanner cannot distinguish fixture from real injection; CI security lane fails on the test that ADDS the security validation` `DEFECT.PROMPT-INJECTION-SCAN-COLLISION-WITH-TESTS.examples=PR #1622 commit 4ed208e74 added convertClaudeCommandToWindsurfWorkflow commandName validation with 22 malicious-name fixtures; scanner matched an instruction-override phrase at tests/windsurf-conversion.test.cjs:122; CI security lane failed even though the test is the security control` @@ -810,6 +841,7 @@ Migration plan: Phase 1 (#3465) seam additions complete; Phase 2 (#3466) targets `DEFECT.WINDOWS-ARGV-OVERFLOW.detect=Windows CI job at "Run unit tests" exits with code 1 within seconds of starting, no node:test output between "run-tests: suite=… files=N: …" line and "Process completed with exit code 1"; same job on Linux/macOS runs full duration` `DEFECT.WINDOWS-ARGV-OVERFLOW.fix-forward=chunk argv into batches whose total length stays under 28,000 chars (headroom under the 32,767 ceiling); run each chunk sequentially; aggregate exit codes (first non-zero wins). Expose RUN_TESTS_MAX_CMDLINE_CHARS env override so cross-platform regression tests can force chunking with short tmp paths` `DEFECT.WINDOWS-ARGV-OVERFLOW.test-anchor=tests/run-tests-harness.test.cjs "Windows argv-overflow chunking (issue #3597)" — 30 long-named fixture files + RUN_TESTS_MAX_CMDLINE_CHARS=2000 → asserts run-tests: chunk N/M marker in stderr; pattern works on every platform` +`DEFECT.WINDOWS-ARGV-OVERFLOW.prevention=a RUNTIME argv-length property (args-array size not statically knowable) — NOT AST-lint-enforceable; addressed at the source by the production run-tests.cjs chunking under RUN_TESTS_MAX_CMDLINE_CHARS plus its test-anchor (tests/run-tests-harness.test.cjs). ADR-1703 Phase 3 (#1720) evaluated and dropped a no-oversized-test-argv lint rule as unsound (it could not detect the canonical execFileSync(node,[...paths]) array overflow)` `DEFECT.SHARED-ARTIFACT-MUTATION-IN-CONCURRENT-TEST.symptom=a test deletes/rewrites a SHARED REAL build artifact or fixture (e.g. gsd-core/bin/lib/*.cjs, the build tsbuildinfo) that other test files require; node --test runs files concurrently, so innocent concurrent tests intermittently fail with "Cannot find module" / ENOENT while the racy test itself passes (victim-not-culprit, leg-asymmetric red); placing mutable build state inside a copied/shipped tree (gsd-core/bin/) additionally races install-test fs.cpSync copies → copyfile ENOENT` `DEFECT.SHARED-ARTIFACT-MUTATION-IN-CONCURRENT-TEST.examples=#996/88e30d53 — bug-969 hardening tests fs.unlinkSync'd + restored the real gsd-core/bin/lib/core.cjs and set tsBuildInfoFile inside gsd-core/bin/ → next red across the full-test matrix (macOS/Windows) + ubuntu-24 coverage leg, ~40-50 MODULE_NOT_FOUND/ENOENT per leg; reproduced locally on iteration 1; fixed #1001/#1002` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c2d14a389..a7a5b0a54 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -817,6 +817,7 @@ The following checks run on every PR in addition to the test suite: | Job | What it checks | How to pass | |-----|----------------|-------------| | `Lint — ESLint` | No source-grep tests (see above), via the `local/no-source-grep` rule | Replace with `runGsdTools()` behavioral tests, or add `// allow-test-rule: ` | +| `Lint — cross-platform portability` | Windows-portability defects in tests, via `local/no-path-literal-in-assert` (more rules land per [ADR-1703](docs/adr/1703-portability-enforcement-architecture.md)) — e.g. a path-returning call asserted against a hardcoded `/`-literal | Normalize the actual: `String(pathFn(...)).replace(/\\/g, '/')`, or structure platform-specific code behind a `process.platform !== 'win32'` guard. **No `eslint-disable`** — see [cross-platform-portability-rules.md](docs/contributing/cross-platform-portability-rules.md) | Run locally before pushing: `npm run lint` (or `npx eslint .`) diff --git a/bin/install.js b/bin/install.js index c951729b2..0f0813a19 100755 --- a/bin/install.js +++ b/bin/install.js @@ -364,6 +364,7 @@ const { resolveRuntimeArtifactLayout, } = require(path.join(_gsdLibDir, 'runtime-artifact-layout.cjs')); const { + assertDestWithinConfigHome, createRuntimeArtifactInstallPlan, createRuntimeArtifactUninstallPlan, } = require(path.join(_gsdLibDir, 'runtime-artifact-install-plan.cjs')); @@ -375,6 +376,31 @@ const { updateCacheFileName, } = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'package-identity.cjs')); +// ADR-1239 Phase B: runtime-artifact install cluster extracted to install-engine.cjs. +// getCommitAttribution STAYS here (impure install-time config I/O); it is injected +// into the engine functions via the resolveAttribution parameter at each call site. +const installEngine = require(path.join(_gsdLibDir, 'install-engine.cjs')); +const { + installRuntimeArtifacts, + uninstallRuntimeArtifacts, + installOpencodeFamilySkills, + _copyStaged, + hasExistingSymlinkBetween, + preserveUserArtifacts, + restoreUserArtifacts, + migrateLegacyDevPreferencesToSkill, + applyOpencodeFamilyPathPrefix, + convertClaudeCommandToOpencodeSkill, + convertClaudeCommandToKiloSkill, + USER_OWNED_ARTIFACTS, + _runLegacyInstallMigrations, + _runLegacyUninstallCleanup, + _removeGsdEntries, + _snapshotDir, + _restoreDir, + _removeHermesBareStemDirs, +} = installEngine; + // Parse args const args = process.argv.slice(2); const hasGlobal = args.includes('--global') || args.includes('-g'); @@ -5628,8 +5654,21 @@ function writeCopilotHookConfig(targetDir) { * Reads agent .md files from source, extracts metadata, writes .toml configs. */ function installCodexConfig(targetDir, agentsSrc, sandboxTier = 'codex-agent-sandbox') { - const configPath = path.join(targetDir, 'config.toml'); - const agentsTomlDir = path.join(targetDir, 'agents'); + // ADR-1239 Phase B write-confinement: every Codex config write stays under targetDir. + const configPath = assertDestWithinConfigHome(targetDir, 'config.toml'); + const agentsTomlDir = assertDestWithinConfigHome(targetDir, 'agents'); + const resolvedTargetRoot = path.resolve(targetDir); + // Symlink-escape guard (parity with _copyStaged / copyWithPathReplacement): the + // lexical gate above does not resolve symlinks, so a pre-existing config.toml or + // agents/ symlink could redirect writes outside targetDir. Reject those. + if ( + hasExistingSymlinkBetween(resolvedTargetRoot, configPath) || + hasExistingSymlinkBetween(resolvedTargetRoot, path.resolve(agentsTomlDir)) + ) { + throw new Error( + `installCodexConfig: a Codex config path under "${targetDir}" contains a symlink escaping the install root — refusing to write`, + ); + } fs.mkdirSync(agentsTomlDir, { recursive: true }); const agentEntries = fs.readdirSync(agentsSrc).filter(f => f.startsWith('gsd-') && f.endsWith('.md')); @@ -5674,7 +5713,16 @@ function installCodexConfig(targetDir, agentsSrc, sandboxTier = 'codex-agent-san // follows the same config-driven precedence as the Claude .md effort key. const effortCfg = readGsdEffectiveEffortConfig(targetDir); const tomlContent = generateCodexAgentToml(name, content, modelOverrides, runtimeResolver, effortCfg, sandboxTier); - fs.writeFileSync(path.join(agentsTomlDir, `${name}.toml`), tomlContent); + // Confine the per-agent write to the agents/ dir itself: a crafted agent + // `name` containing path separators must not escape agents/ (which would let + // it clobber config.toml or write elsewhere under the configHome). + const agentTomlPath = assertDestWithinConfigHome(agentsTomlDir, `${name}.toml`); + if (hasExistingSymlinkBetween(resolvedTargetRoot, agentTomlPath)) { + throw new Error( + `installCodexConfig: agent toml path "${agentTomlPath}" contains a symlink escaping the install root — refusing to write`, + ); + } + fs.writeFileSync(agentTomlPath, tomlContent); } const gsdBlock = generateCodexConfigBlock(agents, targetDir); @@ -6244,69 +6292,9 @@ function convertClaudeToKiloFrontmatter(content, { isAgent = false } = {}) { return `---\n${newFrontmatter}\n---${body}`; } -/** - * Shared SKILL.md writer for the OpenCode-family runtimes (OpenCode + Kilo), - * which share a config schema (Kilo derives from OpenCode). OpenCode discovers - * skills as `skills//SKILL.md` and Kilo follows the same layout - * (https://opencode.ai/docs/skills, https://kilo.ai/docs/customize/skills). - * - * The skill body reuses the runtime's command-frontmatter converter for tool, - * path, and `/gsd:`→`/gsd-` body rewrites, then rebuilds a minimal skill - * frontmatter: only `name` (lowercase-hyphen, must match the containing - * directory) and `description` (1–1024 chars) are emitted, per the OpenCode - * skill spec. The command's `tools:`/`permission:` block is intentionally - * dropped — OpenCode skills are loaded on-demand via the native skill tool and - * inherit the calling agent's permissions. - * - * @param {string} content - Claude command markdown (with YAML frontmatter) - * @param {string} skillName - Skill directory name (e.g. gsd-help) - * @param {(content: string) => string} frontmatterConverter - runtime command converter - * @returns {string} SKILL.md content - */ -function convertClaudeCommandToOpencodeFamilySkill(content, skillName, frontmatterConverter) { - const converted = frontmatterConverter(content); - const { frontmatter, body } = extractFrontmatterAndBody(converted); - let description = `Run GSD workflow ${skillName}.`; - if (frontmatter) { - const maybeDescription = extractFrontmatterField(frontmatter, 'description'); - if (maybeDescription) { - description = maybeDescription; - } - } - description = toSingleLine(description); - // OpenCode skill descriptions must be 1–1024 characters. - if (description.length > 1024) { - description = `${description.slice(0, 1021)}...`; - } - // `name` must be lowercase alphanumeric with single-hyphen separators and - // match the containing directory name (the staged dir is `${skillName}/`). - const name = yamlIdentifier(skillName); - return `---\nname: ${name}\ndescription: ${yamlQuote(description)}\n---\n\n${body.trimStart()}`; -} - -/** - * Convert a Claude command (.md) to an OpenCode skill (SKILL.md). - * Thin wrapper over the shared OpenCode-family writer. - */ -function convertClaudeCommandToOpencodeSkill(content, skillName) { - return convertClaudeCommandToOpencodeFamilySkill( - content, - skillName, - (c) => convertClaudeToOpencodeFrontmatter(c), - ); -} - -/** - * Convert a Claude command (.md) to a Kilo skill (SKILL.md). - * Thin wrapper over the shared OpenCode-family writer (Kilo shares the schema). - */ -function convertClaudeCommandToKiloSkill(content, skillName) { - return convertClaudeCommandToOpencodeFamilySkill( - content, - skillName, - (c) => convertClaudeToKiloFrontmatter(c), - ); -} +// convertClaudeCommandToOpencodeFamilySkill, convertClaudeCommandToOpencodeSkill, +// convertClaudeCommandToKiloSkill: moved to src/install-engine.cts (ADR-1239 Phase B). +// Imported from installEngine above. /** * Convert Claude Code markdown command to Gemini TOML format @@ -6389,30 +6377,8 @@ function convertClaudeToGeminiToml(content, { commandName = null } = {}) { * @param {string} pathPrefix - Path prefix for file references * @param {string} runtime - Target runtime ('claude', 'opencode', or 'kilo') */ -/** - * Apply OpenCode-family (`opencode`/`kilo`) `@file` path-prefix rewrites to a - * RAW Claude command/skill body, BEFORE the frontmatter converter runs. - * - * This is the single source of truth shared by copyFlattenedCommands (commands) - * and installOpencodeFamilySkills (skills) so the two surfaces produce identical - * path references. Applying pathPrefix pre-conversion (rather than rewriting an - * already-converted body) is what avoids the converter's hardcoded default - * config dir leaking into --local / --config-dir installs, and the - * prefix-overlap double-rewrite hazard for custom dirs like `kilo-alt`. (#784) - * - * @param {string} content - raw Claude command markdown - * @param {string} runtime - 'opencode' or 'kilo' - * @param {string} pathPrefix - trailing-slash install-target prefix - * @returns {string} - */ -function applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix) { - content = content.replace(/~\/\.claude\//g, pathPrefix); - content = content.replace(/\$HOME\/\.claude\//g, pathPrefix); - content = content.replace(/\.\/\.claude\//g, `./${getDirName(runtime)}/`); - content = content.replace(/~\/\.opencode\//g, pathPrefix); - content = content.replace(/~\/\.kilo\//g, pathPrefix); - return content; -} +// applyOpencodeFamilyPathPrefix: moved to src/install-engine.cts (ADR-1239 Phase B). +// Imported from installEngine above. function copyFlattenedCommands(srcDir, destDir, prefix, pathPrefix, runtime) { if (!fs.existsSync(srcDir)) { @@ -6551,688 +6517,140 @@ function writeHermesCategoryDescription(categoryDir) { * @param {boolean} isGlobal - Whether this is a global install */ -/** - * Single source of truth for user-owned artifacts inside gsd-core/. - * - * These files are created/refreshed by user-facing workflows (e.g. - * /gsd-profile-user) and must be preserved across reinstalls. Critically, they - * MUST be excluded from gsd-file-manifest.json — otherwise saveLocalPatches() - * will compare a refreshed file against a stale manifest hash and emit a - * spurious "locally modified GSD file" warning (bug #2771). - * - * Invariant: a file is either distribution (manifest-tracked, diff'd against - * manifest) or user artifact (preserved across installs, never diff'd). Never - * both. Both preserveUserArtifacts call sites and writeManifest must agree on - * this list, which is why it lives here as a single constant. - * - * Paths are relative to the gsd-core/ directory. - */ -const USER_OWNED_ARTIFACTS = ['USER-PROFILE.md']; - -/** - * Save user-generated files from destDir to an in-memory map before a wipe. - * - * @param {string} destDir - Directory that is about to be wiped - * @param {string[]} fileNames - Relative file names (e.g. ['USER-PROFILE.md']) to preserve - * @returns {Map} Map of fileName → file content (only entries that existed) - */ -function preserveUserArtifacts(destDir, fileNames) { - const saved = new Map(); - for (const name of fileNames) { - const fullPath = path.join(destDir, name); - if (fs.existsSync(fullPath)) { - try { - saved.set(name, fs.readFileSync(fullPath, 'utf8')); - } catch { /* skip unreadable files */ } - } - } - return saved; -} - -/** - * Restore user-generated files saved by preserveUserArtifacts after a wipe. - * - * @param {string} destDir - Directory that was wiped and recreated - * @param {Map} saved - Map returned by preserveUserArtifacts - */ -function restoreUserArtifacts(destDir, saved) { - for (const [name, content] of saved) { - const fullPath = path.join(destDir, name); - try { - fs.mkdirSync(path.dirname(fullPath), { recursive: true }); - fs.writeFileSync(fullPath, content, 'utf8'); - } catch { /* skip unwritable paths */ } - } -} - -/** - * Migrate a legacy dev-preferences.md (saved from commands/gsd/) into the - * runtime-aware SKILL.md location used by the writer after #2973. - * - * For runtimes with a nested skills layout (e.g. Hermes: skills/gsd//), - * the target is /skills/gsd/dev-preferences/SKILL.md. - * For runtimes with a flat skills layout (prefix='gsd-'), the target is - * /skills/gsd-dev-preferences/SKILL.md. - * - * Skips silently if no legacy file was preserved, or if a SKILL.md already - * exists at the new location (don't clobber user-customized skill content - * — they may have edited the new file directly). Returns true on actual - * migration so callers can log a one-line confirmation. - * - * @param {string} targetDir - Resolved runtime config directory (e.g. ~/.claude) - * @param {Map} saved - Map returned by preserveUserArtifacts - * @param {string} [runtime] - canonical runtime ID (e.g. 'hermes', 'qwen', 'claude') - * @param {'global'|'local'} [scope] - install scope - * @returns {boolean} - true if a file was migrated, false otherwise - */ -function migrateLegacyDevPreferencesToSkill(targetDir, saved, runtime, scope = 'global') { - if (!saved || !saved.has('dev-preferences.md')) return false; - let skillDir; - if (runtime) { - const layout = resolveRuntimeArtifactLayout(runtime, targetDir, scope); - const skillsKindEntry = layout.kinds.find((k) => k.kind === 'skills'); - if (!skillsKindEntry) return false; // runtime has no skills layout at this scope (e.g. cline local) - const stemName = skillsKindEntry.prefix === '' ? 'dev-preferences' : 'gsd-dev-preferences'; - skillDir = path.join(targetDir, skillsKindEntry.destSubpath, stemName); - } else { - // Legacy fallback for callers that have not yet been updated to pass runtime - skillDir = path.join(targetDir, 'skills', 'gsd-dev-preferences'); - } - const skillFile = path.join(skillDir, 'SKILL.md'); - if (fs.existsSync(skillFile)) return false; - try { - fs.mkdirSync(skillDir, { recursive: true }); - fs.writeFileSync(skillFile, saved.get('dev-preferences.md'), 'utf8'); - return true; - } catch { - return false; - } -} +// USER_OWNED_ARTIFACTS, preserveUserArtifacts, restoreUserArtifacts, +// migrateLegacyDevPreferencesToSkill, _copyStaged, _removeGsdEntries, +// _runLegacyInstallMigrations, _runLegacyUninstallCleanup, _snapshotDir, +// _restoreDir, _removeHermesBareStemDirs, installRuntimeArtifacts, +// installOpencodeFamilySkills, uninstallRuntimeArtifacts: +// ALL moved to src/install-engine.cts (ADR-1239 Phase B). +// Imported from installEngine above. // --------------------------------------------------------------------------- -// Phase 2 — Layout-driven install/uninstall orchestrators +// Phase 2 — Layout-driven install/uninstall orchestrators (moved to engine) +// _applyRuntimeRewrites / _stampNonClaudeRuntimeDefaults remain here for +// call sites in copyWithPathReplacement (not moved). // --------------------------------------------------------------------------- - -/** - * Apply per-runtime content rewrites in place across every SKILL.md inside a - * staged directory. Reproduces the rewrite scaffolding that the old - * copyCommandsAsSkills functions applied between read-content and - * converter-call. Applied AFTER stage (which already called the converter); - * rewrites target stable path patterns the converter doesn't touch. - * - * For Qwen/Hermes, branding rewrites (.claude/ → .qwen/ / .hermes/) run - * AFTER the slash-form path replacements but they only catch bare `.claude/` - * patterns (skill-body relative refs) that the slash forms didn't consume. - * This mirrors the exact ordering in the legacy copyCommandsAsClaudeSkills body. - * - * @param {string} stagedDir - * @param {string} runtime - * @param {string} pathPrefix e.g. "~/.codex/" — trailing-slash string - * @param {boolean} [isGlobal=false] true when the install is a global (home-dir) install - */ -// applyRuntimeContentRewritesInPlace: walk loop is now owned by -// runtimeArtifactConversion.applyRuntimeContentRewritesInPlace (ADR-1508 / #1511 Phase 2). -// The const binding above (~line 629) delegates here. Call sites in installRuntimeArtifacts -// pass attribution as the 5th arg (getCommitAttribution(runtime)) per the new contract. - -/** - * Apply per-runtime content rewrites to flat .md files in a staged commands dir. - * Used for runtimes that have a commandsKind in their layout and need content rewrites - * (e.g. augment — replaces ~/.claude/ paths and applies branding conversions). - * - * IMPORTANT: `stageSkillsForProfile()` returns the original source directory unchanged - * on a full/default profile (skills === '*'). This function MUST NOT mutate that source - * directory. It always copies to a temp dir first, rewrites there, and returns the new - * path so the caller installs from the temp copy, not the source. - * - * @param {string} stagedDir directory of staged flat .md command files (may be source dir) - * @param {string} runtime - * @param {string} pathPrefix - * @param {boolean} [isGlobal=false] true when the install is a global (home-dir) install - * @returns {string} path to a temp dir with rewritten files (caller is responsible for cleanup) - */ -// applyRuntimeContentRewritesForCommandsInPlace: copy+rewrite loop is now owned by -// runtimeArtifactConversion.applyRuntimeContentRewritesForCommandsInPlace (ADR-1508 / #1511 Phase 2). -// The const binding above (~line 630) delegates here. Call sites in installRuntimeArtifacts -// pass attribution as the 5th arg (getCommitAttribution(runtime)) per the new contract. - -/** - * Apply the per-runtime rewrite table to a single content string. - * Extracted so it can be unit-tested independently of the filesystem walk. - * - * @param {string} content - * @param {string} runtime - * @param {string} pathPrefix trailing-slash string - * @param {boolean} [isGlobal=false] true when the install is a global (home-dir) install - * @returns {string} - */ -// _applyRuntimeRewrites: single implementation lives in runtimeArtifactConversion -// (ADR-1508 / #1511 Phase 2). Bound here so install.js call sites and exports are -// reference-identical to the conversion module (consistent with the walkers above). -// All call sites are below this line → no TDZ hazard. const _applyRuntimeRewrites = runtimeArtifactConversion._applyRuntimeRewrites; const _stampNonClaudeRuntimeDefaults = runtimeArtifactConversion._stampNonClaudeRuntimeDefaults; /** - * Copy a staged directory's contents into destDir. - * Additive — does not prune (surface.cjs handles pruning). + * Data-driven dispatch table for copyWithPathReplacement (ADR-1239 Phase B). + * Keyed by runtime id. Each entry declares ONLY what that runtime does differently. + * The DEFAULT (no entry, or entry with no md/js key) = identity transform after + * the uniform steps — covers claude, augment, codebuddy, kimi, etc. * - * For skills kind: each child of stagedDir is a `${prefix}${stem}/` dir; copy - * the whole dir into destDir. - * For commands/agents kind: iterate .md files and write them into destDir. - * - commands: write as `${prefix}${stem}.md` unless destSubpath already - * encodes the GSD namespace as its last segment (e.g. `commands/gsd`), in - * which case write as `${stem}.md` (directory IS the namespace). - * - agents: write as-is (files already carry their own `gsd-` prefix). - * For kimi-agents kind: recursively copy generated YAML/prompt files. + * Entry shape: + * mdSkipGenericRewrite?: boolean — skip the ~/.claude/ rewrite block (copilot, antigravity) + * md?: (content, ctx) => string — per-runtime .md transform + * mdReattributeAfter?: boolean — re-run processAttribution after md() (copilot, antigravity) + * mdTomlRenameOnCommand?: boolean — when isCommand, rename dest .md → .toml (gemini) + * js?: (content, ctx) => string — per-runtime .cjs/.js transform (absent = plain copyFileSync) + * + * ctx = { isCommand, isGlobal, dirName, pathPrefix, entryName, runtime } */ -function _copyStaged(stagedDir, destDir, kind) { - if (!fs.existsSync(stagedDir)) return; - fs.mkdirSync(destDir, { recursive: true }); - - if (kind.kind === 'skills') { - // Each child of stagedDir is a prefixed skill directory: gsd-help/, etc. - for (const entry of fs.readdirSync(stagedDir, { withFileTypes: true })) { - if (!entry.isDirectory()) continue; - const src = path.join(stagedDir, entry.name); - const dest = path.join(destDir, entry.name); - fs.cpSync(src, dest, { recursive: true }); - } - return; - } - - if (kind.kind === 'kimi-agents') { - fs.cpSync(stagedDir, destDir, { recursive: true }); - return; - } - - // commands or agents - const entries = fs.readdirSync(stagedDir, { withFileTypes: true }); - // For commands: apply prefix unless the destSubpath's last segment already - // represents the GSD namespace (e.g. 'commands/gsd' → last segment 'gsd'). - const destLast = path.basename(kind.destSubpath); - const prefixStem = kind.prefix ? kind.prefix.replace(/-$/, '') : ''; - const namespacedByDir = kind.kind === 'commands' && destLast === prefixStem; - - for (const entry of entries) { - if (!entry.isFile()) continue; - if (!entry.name.endsWith('.md')) continue; - const stem = entry.name.slice(0, -3); // strip .md - - let destName; - if (kind.kind === 'agents') { - // Agent files already carry the gsd- prefix in the source dir - destName = entry.name; - } else if (namespacedByDir) { - // Directory is the namespace; don't double-prefix the filename - destName = entry.name; - } else { - // Flat commands directory (e.g. command/ for opencode/kilo) - destName = `${kind.prefix}${stem}.md`; - } - - fs.copyFileSync(path.join(stagedDir, entry.name), path.join(destDir, destName)); - } -} - -/** - * Remove GSD-prefixed entries from destDir matching kind.prefix. - * For the prefix='' case: the destSubpath IS the namespace — remove the entire - * destDir. (No current runtime uses prefix='' after #947 reversed Hermes; kept - * as a defensive guard for future runtimes.) - */ -function _removeGsdEntries(destDir, kind) { - if (!fs.existsSync(destDir)) return; - if (kind.kind === 'kimi-agents') { - for (const fileName of ['gsd.yaml', 'gsd.md']) { - fs.rmSync(path.join(destDir, fileName), { force: true }); - } - const subagentsDir = path.join(destDir, 'subagents'); - if (fs.existsSync(subagentsDir)) { - for (const entry of fs.readdirSync(subagentsDir, { withFileTypes: true })) { - if (!entry.isFile()) continue; - if (!entry.name.startsWith('gsd-')) continue; - if (!entry.name.endsWith('.yaml') && !entry.name.endsWith('.md')) continue; - fs.rmSync(path.join(subagentsDir, entry.name), { force: true }); - } - } - return; - } - if (kind.prefix === '') { - // Whole-namespace removal (Hermes nested case — destSubpath is skills/gsd) - // The directory itself is the GSD namespace, so remove it entirely. - fs.rmSync(destDir, { recursive: true, force: true }); - return; - } - for (const entry of fs.readdirSync(destDir, { withFileTypes: true })) { - if (!entry.name.startsWith(kind.prefix)) continue; - fs.rmSync(path.join(destDir, entry.name), { recursive: true, force: true }); - } -} - -/** - * Run legacy install migrations that must execute BEFORE the layout-driven - * copy so stale artifacts are cleaned up before new ones are written. - * - * - Claude/Qwen/Hermes: migrate legacy commands/gsd/dev-preferences.md → - * skills/gsd-dev-preferences/SKILL.md if the old file is present. - * Also removes the legacy commands/gsd/ directory. - * - Hermes: remove flat skills/gsd-STAR directories (pre-2841 layout) before - * writing the new nested skills/gsd/ layout. - * - * @param {string} runtime - * @param {string} configDir resolved runtime config directory - * @param {'global'|'local'} [scope] - */ -function _runLegacyInstallMigrations(runtime, configDir, scope = 'global') { - const legacyCommandsGsd = path.join(configDir, 'commands', 'gsd'); - - // Claude / Qwen / Hermes: clean up legacy commands/gsd/ and preserve dev-preferences - // for migration. The actual migration call is deferred to after all layout cleanup so - // that for Hermes the flat skills/gsd-*/ removal (below) does not delete the freshly - // created skills/gsd-dev-preferences/ skill dir. - let savedLegacyArtifacts = null; - if (runtime === 'claude' || runtime === 'qwen' || runtime === 'hermes') { - if (fs.existsSync(legacyCommandsGsd)) { - savedLegacyArtifacts = preserveUserArtifacts(legacyCommandsGsd, ['dev-preferences.md']); - fs.rmSync(legacyCommandsGsd, { recursive: true }); - } - } - - // Hermes: remove pre-#2841 flat skills/gsd-*/ entries that lived alongside - // the new skills/gsd/ nested layout. - if (runtime === 'hermes') { - const flatSkillsDir = path.join(configDir, 'skills'); - if (fs.existsSync(flatSkillsDir)) { - for (const entry of fs.readdirSync(flatSkillsDir, { withFileTypes: true })) { - if (entry.isDirectory() && entry.name.startsWith('gsd-')) { - fs.rmSync(path.join(flatSkillsDir, entry.name), { recursive: true }); - } - } - } - - // Hermes: bare-stem skills/gsd// cleanup is deferred to AFTER the - // layout-driven install loop in installRuntimeArtifacts, where the exact set - // of staged gsd-/ dirs is known. Removing here (before staging) would - // require readGsdCommandNames() which misses skills like 'dev-preferences' - // that are not in the commands directory. See _removeHermesBareStemDirs(). - } - - // Migrate dev-preferences.md content → runtime-aware SKILL.md location (#2973). - // Done after all layout cleanup so Hermes flat-dir removal does not delete the - // newly created skill dir. No-op if skill file already exists. - if (savedLegacyArtifacts) { - migrateLegacyDevPreferencesToSkill(configDir, savedLegacyArtifacts, runtime, scope); - } -} - -/** - * Run legacy uninstall cleanup that must execute BEFORE the layout-driven - * removal so old-format entries are also cleaned up. - * - * - Claude global/Qwen: remove legacy commands/gsd/ directory if present. - * For Claude LOCAL, commands/gsd/ is the current primary location (not - * legacy), so we skip removal here and let _removeGsdEntries handle it - * with gsd- prefix filtering (preserving user files like dev-preferences.md). - * - Hermes: remove pre-2841 flat skills/gsd-STAR entries. - * - * @param {string} runtime - * @param {string} configDir resolved runtime config directory - * @param {'global'|'local'} [scope] - */ -function _runLegacyUninstallCleanup(runtime, configDir, scope = 'global') { - // commands/gsd/ is a legacy location for Qwen, Hermes, and all Claude installs. - // Prior to #1367 fix, Claude-local used commands/gsd/.md (colon-namespaced). - // After #1367, Claude-local uses flat commands/gsd-.md. The inline uninstall - // block (1c) handles removal of flat files; this function handles the legacy - // commands/gsd/ directory for all Claude scopes (global was already included, - // local is now added since that layout is also legacy post-#1367). - // #2973 / Codex review (bd1f06c9): preserve user-owned dev-preferences.md - // before destructive wipe. Migration to skills/gsd-dev-preferences/SKILL.md - // is deferred and returned so the caller can apply it AFTER layout-driven - // removal — this prevents the layout's gsd-* prefix removal from wiping the - // freshly created skill dir (same pattern as _runLegacyInstallMigrations). - let savedLegacyArtifacts = null; - // commands/gsd/ is a legacy location for Qwen, Hermes, and Claude global. - // Claude local is intentionally excluded: the inline uninstall block (1c) handles - // commands/gsd/ for claude local, preserving dev-preferences.md by restoring it - // to the same location (#1423). Using migrateLegacyDevPreferencesToSkill here - // (which would redirect to skills/) conflicts with the test contract for local installs. - const isLegacyCommandsGsd = runtime === 'qwen' || runtime === 'hermes' || (runtime === 'claude' && scope === 'global'); - if (isLegacyCommandsGsd) { - const legacyCommandsGsd = path.join(configDir, 'commands', 'gsd'); - if (fs.existsSync(legacyCommandsGsd)) { - savedLegacyArtifacts = preserveUserArtifacts(legacyCommandsGsd, ['dev-preferences.md']); - fs.rmSync(legacyCommandsGsd, { recursive: true }); - } - } - - // Hermes: pre-#2841 flat skills/gsd-*/ entries - if (runtime === 'hermes') { - const flatSkillsDir = path.join(configDir, 'skills'); - if (fs.existsSync(flatSkillsDir)) { - for (const entry of fs.readdirSync(flatSkillsDir, { withFileTypes: true })) { - if (entry.isDirectory() && entry.name.startsWith('gsd-')) { - fs.rmSync(path.join(flatSkillsDir, entry.name), { recursive: true }); - } - } - } - - // Hermes: pre-#947 bare-stem skills/gsd// entries (dirs that do NOT - // start with 'gsd-') — the #3664 layout used prefix='' so GSD-owned skills - // had bare names (e.g. skills/gsd/help/). These are stale on uninstall. - const nestedGsdDirForUninstall = path.join(configDir, 'skills', 'gsd'); - if (fs.existsSync(nestedGsdDirForUninstall)) { - for (const entry of fs.readdirSync(nestedGsdDirForUninstall, { withFileTypes: true })) { - if (entry.isDirectory() && !entry.name.startsWith('gsd-')) { - fs.rmSync(path.join(nestedGsdDirForUninstall, entry.name), { recursive: true }); - } - } - } - } - - // Return saved artifacts so the caller can migrate after layout-driven removal. - return savedLegacyArtifacts; -} - -/** - * Layout-driven install orchestrator. - * Runs legacy migrations first, then uses resolveRuntimeArtifactLayout to - * determine what artifact kinds to write and where. - * - * @param {string} runtime canonical runtime ID - * @param {string} configDir resolved runtime config directory - * @param {'global'|'local'} scope - * @param {Object} resolvedProfile from resolveProfile() / resolveEffectiveProfile() - */ -/** - * Deep-snapshot a directory tree into a Map. - * Returns an empty Map if the directory doesn't exist. - * @param {string} dir - * @returns {Map} - */ -function _snapshotDir(dir) { - const files = new Map(); - if (!fs.existsSync(dir)) return files; - const walk = (relPath, absPath) => { - for (const e of fs.readdirSync(absPath, { withFileTypes: true })) { - const childRel = relPath ? path.join(relPath, e.name) : e.name; - const childAbs = path.join(absPath, e.name); - if (e.isDirectory()) walk(childRel, childAbs); - else if (e.isFile()) files.set(childRel, fs.readFileSync(childAbs)); - } - }; - walk('', dir); - return files; -} - -/** - * Restore a directory tree from a Map produced by _snapshotDir. - * @param {string} dir - * @param {Map} snapshot - */ -function _restoreDir(dir, snapshot) { - for (const [relPath, buf] of snapshot) { - const absPath = path.join(dir, relPath); - fs.mkdirSync(path.dirname(absPath), { recursive: true }); - fs.writeFileSync(absPath, buf); - } -} - -/** - * After the layout-driven install loop writes new gsd-/ dirs to - * skills/gsd/, remove any pre-existing bare-stem dirs (skills/gsd//) - * that correspond to the newly installed gsd- entries. - * - * The removal set is derived from the ACTUAL installed skill dirs (every - * entry starting with 'gsd-' that is a directory), so it covers ALL shipped - * GSD skills — including 'dev-preferences' and future additions — without - * relying on readGsdCommandNames() which only enumerates the commands source - * tree and can miss skills that ship outside that directory. - * - * Safety: a bare dir is ONLY removed when a corresponding gsd-/ dir was - * installed this run. A user-owned dir 'skills/gsd/my-workflow/' that has no - * matching 'skills/gsd/gsd-my-workflow/' is never touched. - * - * @param {string} nestedGsdDir absolute path to skills/gsd/ category dir - */ -function _removeHermesBareStemDirs(nestedGsdDir) { - if (!fs.existsSync(nestedGsdDir)) return; - const entries = fs.readdirSync(nestedGsdDir, { withFileTypes: true }); - - // Collect the set of stems that were installed as gsd-/ this run. - const installedStems = new Set(); - for (const entry of entries) { - if (entry.isDirectory() && entry.name.startsWith('gsd-')) { - installedStems.add(entry.name.slice('gsd-'.length)); // e.g. 'quick', 'dev-preferences' - } - } - - // Remove any bare / dir for which gsd-/ was just installed. - for (const entry of entries) { - if (entry.isDirectory() && !entry.name.startsWith('gsd-') && installedStems.has(entry.name)) { - fs.rmSync(path.join(nestedGsdDir, entry.name), { recursive: true }); - } - } -} - -function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile) { - // Legacy cleanup before layout-driven writes - _runLegacyInstallMigrations(runtime, configDir, scope); - - const layout = resolveRuntimeArtifactLayout(runtime, configDir, scope); - const planResult = createRuntimeArtifactInstallPlan({ - layout, - resolvedProfile, - homedir: () => os.homedir(), - platform: process.platform, - resolveAttribution: getCommitAttribution, - }); - - const cleanupDirs = planResult.ok ? planResult.plan.cleanupDirs : planResult.cleanupDirs; - try { - if (!planResult.ok) { - throw new Error(planResult.message); - } - - const kindsByName = new Map(layout.kinds.map((kind) => [kind.kind, kind])); - for (const item of planResult.plan.items) { - const kind = kindsByName.get(item.kind); - if (!kind) throw new Error(`Install plan returned unknown artifact kind: ${item.kind}`); - const dest = item.destDir; - fs.mkdirSync(dest, { recursive: true }); - if (kind.kind === 'skills' && fs.existsSync(dest)) { - // Pre-prune: snapshot user-owned content before _removeGsdEntries wipes it, - // then restore after. This preserves user dirs across a wipe-and-replace - // install (#2973 / #3664). - // - // All runtimes (incl. Hermes after #947) use prefix='gsd-'. - // _removeGsdEntries removes only gsd-* entries; non-gsd-* user dirs are - // untouched. Preserve the explicit user-owned GSD-prefixed skill - // gsd-dev-preferences, which GSD does not reinstall from source but must - // survive the prune (#2973). - const toPreserve = new Map(); // dirName -> Map - - { - // Preserve explicitly user-owned GSD-prefixed skill dirs. - // gsd-dev-preferences is the sole user-customisable skill in this category. - const USER_OWNED_SKILL_DIRS = ['gsd-dev-preferences']; - for (const dirName of USER_OWNED_SKILL_DIRS) { - const skillDir = path.join(dest, dirName); - if (!fs.existsSync(skillDir)) continue; - const snap = _snapshotDir(skillDir); - if (snap.size > 0) toPreserve.set(dirName, snap); - } - } - - _removeGsdEntries(dest, kind); - _copyStaged(item.sourceDir, dest, kind); - - // Restore user-owned dirs after the prune+copy - for (const [dirName, snap] of toPreserve) { - _restoreDir(path.join(dest, dirName), snap); - } - } else { - // For non-skills kinds (commands, agents): no user content to preserve; - // just prune stale gsd-* entries and copy new ones. - _removeGsdEntries(dest, kind); - _copyStaged(item.sourceDir, dest, kind); - } - } - } finally { - for (const dir of cleanupDirs) { - try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best-effort */ } - } - } - - // Hermes: after the install loop has written all gsd-/ dirs to - // skills/gsd/, remove any stale bare-stem dirs (skills/gsd//) that - // correspond to the newly installed gsd- entries. This is the robust - // replacement for the readGsdCommandNames()-based pre-install cleanup that - // missed skills like 'dev-preferences' (#947 adversarial review). - // - // We run this AFTER the install loop so the installed set is authoritative: - // every gsd-/ present now was written this run (or was there before - // with the same prefix). User-owned bare dirs with no gsd- counterpart - // are untouched. - if (runtime === 'hermes') { - const nestedGsdDirForCleanup = path.join(configDir, 'skills', 'gsd'); - _removeHermesBareStemDirs(nestedGsdDirForCleanup); - } -} - -/** - * Install the skills layout kind for an OpenCode-family runtime (OpenCode/Kilo). - * - * These runtimes do NOT go through installRuntimeArtifacts (their commands use a - * bespoke flattened-command writer), so this writes ONLY the skills kind - * alongside their existing command/ + agents/ surfaces. Uninstall is already - * layout-driven (uninstallRuntimeArtifacts iterates layout.kinds), so the - * skills/ dir is cleaned up automatically once the layout declares it. - * - * `rawCommandsDir` MUST be the SAME staged command directory the flattened - * command writer consumes (the caller passes its `_stageSkills()` output) so the - * command/ and skills/ surfaces always cover the identical, profile-resolved set - * — including the `--minimal`/`--core-only` alias path, which stages differently - * from a plain `--profile=core`. - * - * Mirrors copyFlattenedCommands exactly per file — pathPrefix rewrite → - * attribution → command→skill conversion — guaranteeing command/ and skills/ - * bodies match byte-for-byte for global, --local, and --config-dir installs. - * We deliberately do NOT use skillsKindEntry.stage(): that converts before any - * pathPrefix is known, so its bodies would carry the converter's hardcoded - * default config dir. (#784) - * - * @param {string} runtime - 'opencode' or 'kilo' - * @param {string} targetDir - resolved runtime config directory - * @param {string} rawCommandsDir - staged RAW Claude command dir (caller's _stageSkills output) - * @param {string} pathPrefix - computed config-path prefix for body rewrites - * @returns {number} number of gsd-* skill directories written - */ -function installOpencodeFamilySkills(runtime, targetDir, rawCommandsDir, pathPrefix) { - const layout = resolveRuntimeArtifactLayout(runtime, targetDir); - const skillsKindEntry = layout.kinds.find((k) => k.kind === 'skills'); - if (!skillsKindEntry) return 0; - const rawDir = rawCommandsDir; - if (!rawDir || !fs.existsSync(rawDir)) return 0; - - const converter = runtime === 'kilo' - ? convertClaudeCommandToKiloSkill - : convertClaudeCommandToOpencodeSkill; - - const dest = path.join(targetDir, skillsKindEntry.destSubpath); - fs.mkdirSync(dest, { recursive: true }); - - // Preserve user-owned GSD-prefixed skill dirs across the gsd-* prune. - // gsd-dev-preferences is generated by the user (via generate-dev-preferences) - // and lives at /skills/gsd-dev-preferences — _removeGsdEntries - // would otherwise wipe it. Mirrors the preservation in installRuntimeArtifacts - // (#2973). - const USER_OWNED_SKILL_DIRS = ['gsd-dev-preferences']; - const toPreserve = new Map(); // dirName -> Map - for (const dirName of USER_OWNED_SKILL_DIRS) { - const skillDir = path.join(dest, dirName); - if (!fs.existsSync(skillDir)) continue; - const snap = _snapshotDir(skillDir); - if (snap.size > 0) toPreserve.set(dirName, snap); - } - - _removeGsdEntries(dest, skillsKindEntry); - - let count = 0; - for (const entry of fs.readdirSync(rawDir, { withFileTypes: true })) { - if (!entry.isFile() || !entry.name.endsWith('.md')) continue; - const stem = entry.name.slice(0, -3); - const skillName = `${skillsKindEntry.prefix}${stem}`; - let content = fs.readFileSync(path.join(rawDir, entry.name), 'utf8'); - content = applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix); - content = processAttribution(content, getCommitAttribution(runtime)); - content = converter(content, skillName); - const skillDir = path.join(dest, skillName); - fs.mkdirSync(skillDir, { recursive: true }); - fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content); - count++; - } - - // Restore user-owned dirs after the prune+copy. - for (const [dirName, snap] of toPreserve) { - _restoreDir(path.join(dest, dirName), snap); - } - - return count; -} - -/** - * Layout-driven uninstall orchestrator. - * Runs legacy cleanup first, then uses resolveRuntimeArtifactLayout to - * determine which GSD-owned entries to remove. - * - * @param {string} runtime canonical runtime ID - * @param {string} configDir resolved runtime config directory - * @param {'global'|'local'} scope - */ -function uninstallRuntimeArtifacts(runtime, configDir, scope) { - // Legacy cleanup before layout-driven removal (scope-aware to avoid - // removing Claude local commands/gsd/ which is the primary install dir). - // Returns saved user artifacts so we can migrate AFTER layout removal - // (the layout's gsd-* prefix pass would wipe a skill dir created here). - const savedLegacyArtifacts = _runLegacyUninstallCleanup(runtime, configDir, scope); - - const layout = resolveRuntimeArtifactLayout(runtime, configDir, scope); - const plan = createRuntimeArtifactUninstallPlan(layout); - const kindsByName = new Map(layout.kinds.map((kind) => [kind.kind, kind])); - for (const item of plan.items) { - const kind = kindsByName.get(item.kind); - if (!kind) { - throw new Error(`Runtime artifact uninstall plan referenced unknown kind: ${item.kind}`); - } - _removeGsdEntries(item.destDir, kind); - } - - // Hermes: after removing gsd-* skill dirs from skills/gsd/, also remove - // the GSD-managed DESCRIPTION.md and then the category dir itself if it - // contains no user content (#947). _removeGsdEntries removed gsd-* dirs - // but left the category container and DESCRIPTION.md intact. - if (runtime === 'hermes') { - const nestedGsdDir = path.join(configDir, 'skills', 'gsd'); - if (fs.existsSync(nestedGsdDir)) { - // Remove GSD-owned DESCRIPTION.md (written by writeHermesCategoryDescription) - fs.rmSync(path.join(nestedGsdDir, 'DESCRIPTION.md'), { force: true }); - // Remove the category dir if empty (no user content remaining) - const remaining = fs.readdirSync(nestedGsdDir, { withFileTypes: true }); - if (remaining.length === 0) { - fs.rmSync(nestedGsdDir, { recursive: true, force: true }); - } - } - } - - // #2973 / Codex review (bd1f06c9): migrate dev-preferences.md to the - // runtime-aware SKILL.md location after all layout-driven removal is - // complete. Do NOT restore to commands/gsd/ — the user is uninstalling. - if (savedLegacyArtifacts) { - migrateLegacyDevPreferencesToSkill(configDir, savedLegacyArtifacts, runtime, scope); - } -} +const RUNTIME_CONTENT_DISPATCH = { + opencode: { + md: (content) => convertClaudeToOpencodeFrontmatter(content), + }, + kilo: { + md: (content) => convertClaudeToKiloFrontmatter(content), + }, + gemini: { + md: (content, ctx) => + convertClaudeToGeminiMarkdown(content, { + isCommand: ctx.isCommand, + commandName: ctx.isCommand ? ctx.entryName.replace(/\.md$/, '') : null, + }), + mdTomlRenameOnCommand: true, + }, + codex: { + md: (content) => convertClaudeToCodexMarkdown(content), + }, + copilot: { + mdSkipGenericRewrite: true, + md: (content, ctx) => convertClaudeToCopilotContent(content, ctx.isGlobal), + mdReattributeAfter: true, + js: (content, ctx) => convertClaudeToCopilotContent(content, ctx.isGlobal), + }, + antigravity: { + mdSkipGenericRewrite: true, + md: (content, ctx) => convertClaudeToAntigravityContent(content, ctx.isGlobal), + mdReattributeAfter: true, + js: (content, ctx) => convertClaudeToAntigravityContent(content, ctx.isGlobal), + }, + cursor: { + md: (content) => convertClaudeToCursorMarkdown(content), + js: (content) => { + content = content.replace(/gsd:/gi, 'gsd-'); + content = content.replace(/\.claude\/skills\//g, '.cursor/skills/'); + content = content.replace(/CLAUDE\.md/g, '.cursor/rules/'); + content = content.replace(/\bClaude Code\b/g, 'Cursor'); + return content; + }, + }, + windsurf: { + md: (content) => convertClaudeToWindsurfMarkdown(content), + js: (content) => { + // Workspace skills install to .devin/ (Devin Desktop preferred dir, #1085). + content = content.replace(/gsd:/gi, 'gsd-'); + content = content.replace(/\.claude\/skills\//g, '.devin/skills/'); + content = content.replace(/CLAUDE\.md/g, '.devin/rules'); + content = content.replace(/\bClaude Code\b/g, 'Windsurf'); + return content; + }, + }, + trae: { + md: (content) => convertClaudeToTraeMarkdown(content), + js: (content) => { + content = content.replace(/\/gsd:([a-z0-9-]+)/g, (_, commandName) => { + return `/gsd-${commandName}`; + }); + content = content.replace(/\.claude\/skills\//g, '.trae/skills/'); + content = content.replace(/CLAUDE\.md/g, '.trae/rules/'); + content = content.replace(/\bClaude Code\b/g, 'Trae'); + return content; + }, + }, + cline: { + md: (content) => convertClaudeToCliineMarkdown(content), + js: (content) => { + content = content.replace(/\.claude\/skills\//g, '.cline/skills/'); + content = content.replace(/CLAUDE\.md/g, '.clinerules'); + content = content.replace(/\bClaude Code\b/g, 'Cline'); + return content; + }, + }, + qwen: { + md: (content) => { + content = content.replace(/CLAUDE\.md/g, 'QWEN.md'); + content = content.replace(/\bClaude Code\b/g, 'Qwen Code'); + content = content.replace(/\.claude\//g, '.qwen/'); + return content; + }, + js: (content) => { + content = content.replace(/\.claude\/skills\//g, '.qwen/skills/'); + content = content.replace(/\.claude\//g, '.qwen/'); + content = content.replace(/CLAUDE\.md/g, 'QWEN.md'); + content = content.replace(/\bClaude Code\b/g, 'Qwen Code'); + return content; + }, + }, + hermes: { + md: (content) => { + content = content.replace(/CLAUDE\.md/g, 'HERMES.md'); + content = content.replace(/\bClaude Code\b/g, 'Hermes Agent'); + content = content.replace(/\.claude\//g, '.hermes/'); + return content; + }, + js: (content) => { + content = content.replace(/\.claude\/skills\//g, '.hermes/skills/'); + content = content.replace(/\.claude\//g, '.hermes/'); + content = content.replace(/CLAUDE\.md/g, 'HERMES.md'); + content = content.replace(/\bClaude Code\b/g, 'Hermes Agent'); + return content; + }, + }, +}; /** * Recursively copy directory, replacing paths in .md files @@ -7244,22 +6662,28 @@ function uninstallRuntimeArtifacts(runtime, configDir, scope) { * @param {boolean} isCommand - Whether the source is a command directory * @param {boolean} isGlobal - Whether the install is global */ -function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand = false, isGlobal = false) { - const isOpencode = runtime === 'opencode'; - const isKilo = runtime === 'kilo'; - const isGemini = runtime === 'gemini'; - const isCodex = runtime === 'codex'; - const isCopilot = runtime === 'copilot'; - const isAntigravity = runtime === 'antigravity'; - const isCursor = runtime === 'cursor'; - const isWindsurf = runtime === 'windsurf'; - const isAugment = runtime === 'augment'; - const isTrae = runtime === 'trae'; - const isQwen = runtime === 'qwen'; - const isHermes = runtime === 'hermes'; - const isCline = runtime === 'cline'; +function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand = false, isGlobal = false, confinementRoot) { const dirName = getDirName(runtime); + // ADR-1239 Phase B write-confinement: refuse to wipe/write a destDir that + // escapes the caller-declared install root. Runs BEFORE the rmSync below so a + // crafted destDir can never delete or write outside confinementRoot. + if (confinementRoot === undefined) { + throw new Error( + 'copyWithPathReplacement: confinementRoot is required to confine writes to the install root — refusing to write', + ); + } + const resolvedConfinementRoot = path.resolve(confinementRoot); + const resolvedDestDir = assertDestWithinConfigHome(confinementRoot, destDir); + if (hasExistingSymlinkBetween(resolvedConfinementRoot, resolvedDestDir)) { + throw new Error( + `copyWithPathReplacement: destDir "${destDir}" contains a symlink escaping the install root "${confinementRoot}" — refusing to write`, + ); + } + // Use the validated absolute path for all writes below so the gate validates + // exactly what is written (a relative destDir would otherwise resolve to cwd). + destDir = resolvedDestDir; + // Clean install: remove existing destination to prevent orphaned files if (fs.existsSync(destDir)) { fs.rmSync(destDir, { recursive: true }); @@ -7273,12 +6697,15 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand const destPath = path.join(destDir, entry.name); if (entry.isDirectory()) { - copyWithPathReplacement(srcPath, destPath, pathPrefix, runtime, isCommand, isGlobal); + copyWithPathReplacement(srcPath, destPath, pathPrefix, runtime, isCommand, isGlobal, confinementRoot); } else if (entry.name.endsWith('.md')) { + const dispatch = RUNTIME_CONTENT_DISPATCH[runtime] || {}; + const ctx = { isCommand, isGlobal, dirName, pathPrefix, entryName: entry.name, runtime }; + // Replace ~/.claude/ and $HOME/.claude/ and ./.claude/ with runtime-appropriate paths - // Skip generic replacement for Copilot — convertClaudeToCopilotContent handles all paths + // Skip generic replacement for Copilot/Antigravity — their converters handle all paths let content = fs.readFileSync(srcPath, 'utf8'); - if (!isCopilot && !isAntigravity) { + if (!dispatch.mdSkipGenericRewrite) { const globalClaudeRegex = /~\/\.claude\//g; const globalClaudeHomeRegex = /\$HOME\/\.claude\//g; const localClaudeRegex = /\.\/\.claude\//g; @@ -7313,112 +6740,25 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand // colon-canonical runtimes (Gemini). content = normalizeAgentBodyForRuntime(content, runtime, readGsdCommandNames()); - // Convert frontmatter for opencode compatibility - if (isOpencode || isKilo) { - content = isKilo - ? convertClaudeToKiloFrontmatter(content) - : convertClaudeToOpencodeFrontmatter(content); - fs.writeFileSync(destPath, content); - } else if (isGemini) { - // Apply Gemini-specific Markdown transformations (slash commands, TOML). - // #778: thread the command name (file stem) so per-command TOML - // enrichment (live-state injection) can target a specific command. - const geminiCommandName = isCommand ? entry.name.replace(/\.md$/, '') : null; - const processed = convertClaudeToGeminiMarkdown(content, { isCommand, commandName: geminiCommandName }); - const finalPath = isCommand ? destPath.replace(/\.md$/, '.toml') : destPath; - fs.writeFileSync(finalPath, processed); - } else if (isCodex) { - content = convertClaudeToCodexMarkdown(content); - fs.writeFileSync(destPath, content); - } else if (isCopilot) { - content = convertClaudeToCopilotContent(content, isGlobal); - content = processAttribution(content, getCommitAttribution(runtime)); - fs.writeFileSync(destPath, content); - } else if (isAntigravity) { - content = convertClaudeToAntigravityContent(content, isGlobal); - content = processAttribution(content, getCommitAttribution(runtime)); - fs.writeFileSync(destPath, content); - } else if (isCursor) { - content = convertClaudeToCursorMarkdown(content); - fs.writeFileSync(destPath, content); - } else if (isWindsurf) { - content = convertClaudeToWindsurfMarkdown(content); - fs.writeFileSync(destPath, content); - } else if (isTrae) { - content = convertClaudeToTraeMarkdown(content); - fs.writeFileSync(destPath, content); - } else if (isCline) { - content = convertClaudeToCliineMarkdown(content); - fs.writeFileSync(destPath, content); - } else if (isQwen) { - content = content.replace(/CLAUDE\.md/g, 'QWEN.md'); - content = content.replace(/\bClaude Code\b/g, 'Qwen Code'); - content = content.replace(/\.claude\//g, '.qwen/'); - fs.writeFileSync(destPath, content); - } else if (isHermes) { - content = content.replace(/CLAUDE\.md/g, 'HERMES.md'); - content = content.replace(/\bClaude Code\b/g, 'Hermes Agent'); - content = content.replace(/\.claude\//g, '.hermes/'); + // Apply per-runtime .md converter (if any) + if (dispatch.md) content = dispatch.md(content, ctx); + + // Re-run attribution after converter for runtimes that need it (copilot, antigravity) + if (dispatch.mdReattributeAfter) content = processAttribution(content, getCommitAttribution(runtime)); + + // Gemini: rename .md → .toml for command files + const finalPath = (dispatch.mdTomlRenameOnCommand && isCommand) ? destPath.replace(/\.md$/, '.toml') : destPath; + fs.writeFileSync(finalPath, content); + } else if (entry.name.endsWith('.cjs') || entry.name.endsWith('.js')) { + const dispatch = RUNTIME_CONTENT_DISPATCH[runtime] || {}; + if (dispatch.js) { + const ctx = { isCommand, isGlobal, dirName, pathPrefix, entryName: entry.name, runtime }; + let content = fs.readFileSync(srcPath, 'utf8'); + content = dispatch.js(content, ctx); fs.writeFileSync(destPath, content); } else { - fs.writeFileSync(destPath, content); + fs.copyFileSync(srcPath, destPath); } - } else if (isCopilot && (entry.name.endsWith('.cjs') || entry.name.endsWith('.js'))) { - // Copilot: also transform .cjs/.js files for CONV-06 and CONV-07 - let content = fs.readFileSync(srcPath, 'utf8'); - content = convertClaudeToCopilotContent(content, isGlobal); - fs.writeFileSync(destPath, content); - } else if (isAntigravity && (entry.name.endsWith('.cjs') || entry.name.endsWith('.js'))) { - // Antigravity: also transform .cjs/.js files for path/command conversions - let content = fs.readFileSync(srcPath, 'utf8'); - content = convertClaudeToAntigravityContent(content, isGlobal); - fs.writeFileSync(destPath, content); - } else if (isCursor && (entry.name.endsWith('.cjs') || entry.name.endsWith('.js'))) { - // For Cursor, also convert Claude references in JS/CJS utility scripts - let jsContent = fs.readFileSync(srcPath, 'utf8'); - jsContent = jsContent.replace(/gsd:/gi, 'gsd-'); - jsContent = jsContent.replace(/\.claude\/skills\//g, '.cursor/skills/'); - jsContent = jsContent.replace(/CLAUDE\.md/g, '.cursor/rules/'); - jsContent = jsContent.replace(/\bClaude Code\b/g, 'Cursor'); - fs.writeFileSync(destPath, jsContent); - } else if (isWindsurf && (entry.name.endsWith('.cjs') || entry.name.endsWith('.js'))) { - // For Windsurf/Devin, also convert Claude references in JS/CJS utility scripts. - // Workspace skills install to .devin/ (Devin Desktop preferred dir, #1085). - let jsContent = fs.readFileSync(srcPath, 'utf8'); - jsContent = jsContent.replace(/gsd:/gi, 'gsd-'); - jsContent = jsContent.replace(/\.claude\/skills\//g, '.devin/skills/'); - jsContent = jsContent.replace(/CLAUDE\.md/g, '.devin/rules'); - jsContent = jsContent.replace(/\bClaude Code\b/g, 'Windsurf'); - fs.writeFileSync(destPath, jsContent); - } else if (isTrae && (entry.name.endsWith('.cjs') || entry.name.endsWith('.js'))) { - let jsContent = fs.readFileSync(srcPath, 'utf8'); - jsContent = jsContent.replace(/\/gsd:([a-z0-9-]+)/g, (_, commandName) => { - return `/gsd-${commandName}`; - }); - jsContent = jsContent.replace(/\.claude\/skills\//g, '.trae/skills/'); - jsContent = jsContent.replace(/CLAUDE\.md/g, '.trae/rules/'); - jsContent = jsContent.replace(/\bClaude Code\b/g, 'Trae'); - fs.writeFileSync(destPath, jsContent); - } else if (isCline && (entry.name.endsWith('.cjs') || entry.name.endsWith('.js'))) { - let jsContent = fs.readFileSync(srcPath, 'utf8'); - jsContent = jsContent.replace(/\.claude\/skills\//g, '.cline/skills/'); - jsContent = jsContent.replace(/CLAUDE\.md/g, '.clinerules'); - jsContent = jsContent.replace(/\bClaude Code\b/g, 'Cline'); - fs.writeFileSync(destPath, jsContent); - } else if (isQwen && (entry.name.endsWith('.cjs') || entry.name.endsWith('.js'))) { - let jsContent = fs.readFileSync(srcPath, 'utf8'); - jsContent = jsContent.replace(/\.claude\/skills\//g, '.qwen/skills/'); - jsContent = jsContent.replace(/\.claude\//g, '.qwen/'); - jsContent = jsContent.replace(/CLAUDE\.md/g, 'QWEN.md'); - jsContent = jsContent.replace(/\bClaude Code\b/g, 'Qwen Code'); - fs.writeFileSync(destPath, jsContent); - } else if (isHermes && (entry.name.endsWith('.cjs') || entry.name.endsWith('.js'))) { - let jsContent = fs.readFileSync(srcPath, 'utf8'); - jsContent = jsContent.replace(/\.claude\/skills\//g, '.hermes/skills/'); - jsContent = jsContent.replace(/\.claude\//g, '.hermes/'); - jsContent = jsContent.replace(/CLAUDE\.md/g, 'HERMES.md'); - jsContent = jsContent.replace(/\bClaude Code\b/g, 'Hermes Agent'); - fs.writeFileSync(destPath, jsContent); } else { fs.copyFileSync(srcPath, destPath); } @@ -8594,28 +7934,8 @@ function resolveInstallRelativePath(baseDir, relPath) { return { relPath: normalized, fullPath }; } -function hasExistingSymlinkBetween(root, fullPath) { - const resolvedRoot = path.resolve(root); - const resolvedFullPath = path.resolve(fullPath); - if (resolvedFullPath !== resolvedRoot && !resolvedFullPath.startsWith(resolvedRoot + path.sep)) { - return true; - } - - let cursor = resolvedRoot; - if (fs.existsSync(cursor) && fs.lstatSync(cursor).isSymbolicLink()) { - return true; - } - - const relative = path.relative(resolvedRoot, resolvedFullPath); - for (const segment of relative.split(path.sep)) { - if (!segment) continue; - cursor = path.join(cursor, segment); - if (!fs.existsSync(cursor)) return false; - if (fs.lstatSync(cursor).isSymbolicLink()) return true; - } - - return false; -} +// hasExistingSymlinkBetween: moved to src/install-engine.cts (ADR-1239 Phase B). +// Imported from installEngine above. /** * Write file manifest after installation for future modification detection @@ -8861,7 +8181,7 @@ function populatePristineDir({ packageSrc, pristineDir, modified, runtime, pathP const srcDir = path.join(packageSrc, top); const stageDir = path.join(stageRoot, top); if (!fs.existsSync(srcDir)) continue; - copyWithPathReplacement(srcDir, stageDir, pathPrefix, runtime, false, isGlobal); + copyWithPathReplacement(srcDir, stageDir, pathPrefix, runtime, false, isGlobal, stageRoot); } for (const relPath of safeModified) { @@ -9589,7 +8909,7 @@ function install(isGlobal, runtime = 'claude', options = {}) { if (_isSkillsRuntime) { // Layout-driven install for skills-based runtimes (full and minimal modes) const scope = isGlobal ? 'global' : 'local'; - installRuntimeArtifacts(runtime, targetDir, scope, _resolvedProfile); + installRuntimeArtifacts(runtime, targetDir, scope, _resolvedProfile, getCommitAttribution); // #1326 — Codex only: remove stale agents/openai.yaml sidecars from managed // gsd-* skill dirs. Prior installs wrote these files so Codex would show a @@ -9749,7 +9069,7 @@ function install(isGlobal, runtime = 'claude', options = {}) { // Kilo support native, on-demand skills in addition to flat commands — see // resolveRuntimeArtifactLayout's opencode/kilo entries. Derive skills from // the SAME staged command set (gsdSrc) so both surfaces match exactly. (#784) - const _skillCount = installOpencodeFamilySkills(runtime, targetDir, gsdSrc, pathPrefix); + const _skillCount = installOpencodeFamilySkills(runtime, targetDir, gsdSrc, pathPrefix, getCommitAttribution); if (_skillCount > 0) { console.log(` ${green}✓${reset} Installed ${_skillCount} skills to skills/`); } else { @@ -9804,7 +9124,7 @@ function install(isGlobal, runtime = 'claude', options = {}) { fs.mkdirSync(commandsDir, { recursive: true }); const gsdSrc = _stageSkills(_commandsDir); const gsdDest = path.join(commandsDir, 'gsd'); - copyWithPathReplacement(gsdSrc, gsdDest, pathPrefix, runtime, true, isGlobal); + copyWithPathReplacement(gsdSrc, gsdDest, pathPrefix, runtime, true, isGlobal, targetDir); if (verifyInstalled(gsdDest, 'commands/gsd')) { console.log(` ${green}✓${reset} Installed commands/gsd`); } else { @@ -9886,7 +9206,7 @@ function install(isGlobal, runtime = 'claude', options = {}) { const skillSrc = path.join(src, 'gsd-core'); const skillDest = path.join(targetDir, 'gsd-core'); const savedGsdArtifacts = preserveUserArtifacts(skillDest, USER_OWNED_ARTIFACTS); - copyWithPathReplacement(skillSrc, skillDest, pathPrefix, runtime, false, isGlobal); + copyWithPathReplacement(skillSrc, skillDest, pathPrefix, runtime, false, isGlobal, targetDir); restoreUserArtifacts(skillDest, savedGsdArtifacts); if (verifyInstalled(skillDest, 'gsd-core')) { console.log(` ${green}✓${reset} Installed workflow assets`); @@ -9933,7 +9253,7 @@ function install(isGlobal, runtime = 'claude', options = {}) { const commandsSrc = path.join(src, 'commands', 'gsd'); const commandsDest = path.join(skillDest, 'commands', 'gsd'); if (fs.existsSync(commandsSrc)) { - copyWithPathReplacement(commandsSrc, commandsDest, pathPrefix, runtime, true, isGlobal); + copyWithPathReplacement(commandsSrc, commandsDest, pathPrefix, runtime, true, isGlobal, targetDir); console.log(` ${green}✓${reset} Installed command bodies to gsd-core/commands/gsd/ (workflow delegation targets)`); } } @@ -9975,11 +9295,24 @@ function install(isGlobal, runtime = 'claude', options = {}) { agentsSrc = _stageAgents(path.join(src, 'agents')); const agentsDest = path.join(targetDir, 'agents'); + // ADR-1235 §1: runtimes that have been migrated to the descriptor-driven agent + // path (installRuntimeArtifacts → convertedAgentsKind). The descriptor path + // applies path-rewrite + attribution + converter + normalize via + // stageAgentsForRuntimeWithConverter (with agentCtx pre-converter threading) in + // createRuntimeArtifactInstallPlan. Their agents are already written ABOVE + // (by installRuntimeArtifacts at line 8912), which also performs its own + // stale-file prune pass. The inline stale-removal + inline loop both skip them. + // Trivial group (cursor/windsurf/augment/trae/codebuddy) cut over together. + // cline is excluded: it takes a rules-only local branch and has a local/global + // complication that the descriptor-driven path does not handle correctly. + const _DESCRIPTOR_AGENTS_RUNTIMES = new Set(['cursor', 'windsurf', 'augment', 'trae', 'codebuddy']); + // Always remove stale gsd-* agents first so re-installing with // `--minimal` actually shrinks a previously-full install. // For Codex this also covers per-agent `.toml` files alongside the `.md` // sources so a full → minimal switch doesn't leave stale registrations. - if (fs.existsSync(agentsDest)) { + // Skipped for descriptor-agent runtimes (installRuntimeArtifacts prunes). + if (!_DESCRIPTOR_AGENTS_RUNTIMES.has(runtime) && fs.existsSync(agentsDest)) { for (const file of fs.readdirSync(agentsDest)) { if ( file.startsWith('gsd-') && @@ -9992,6 +9325,10 @@ function install(isGlobal, runtime = 'claude', options = {}) { if (isKimi) { console.log(` ${dim}↳${reset} Kimi custom agent YAML/prompt artifacts were installed via runtime artifact layout`); + } else if (_DESCRIPTOR_AGENTS_RUNTIMES.has(runtime)) { + // installRuntimeArtifacts already wrote agents + handles stale-file cleanup + // via its own prune pass. No further action needed. + console.log(` ${dim}↳${reset} Agents installed via descriptor-driven layout (${runtime})`); } else if (isMinimalMode(_effectiveInstallMode)) { // Codex registers agents in `config.toml` via `[agents.gsd-*]` sections. // Without stripping them here, a full → minimal reinstall would leave the @@ -12349,6 +11686,8 @@ module.exports = { // runtimeArtifactConversion spread (#1559). processAttribution, applyRuntimeContentRewritesForCommandsInPlace, + _copyStaged, + copyWithPathReplacement, }; // Main logic — only run when not loaded as a module for testing diff --git a/capabilities/antigravity/capability.json b/capabilities/antigravity/capability.json index 3ad49e14d..ef7680f2f 100644 --- a/capabilities/antigravity/capability.json +++ b/capabilities/antigravity/capability.json @@ -24,6 +24,7 @@ ], "probeExists": "gsd-core/VERSION" }, + "localConfigDir": ".agents", "configFormat": "settings-json", "artifactLayout": { "global": [ @@ -55,6 +56,16 @@ "installSurface": "settings-json", "writesSharedSettings": true, "permissionWriter": null, - "extendedHookEvents": [] + "extendedHookEvents": [], + "hostIntegration": { + "embeddingMode": "declarative", + "commandSurface": "slash-file", + "dispatch": { "namedDispatch": "undocumented", "nested": "undocumented", "maxDepth": "undocumented", "background": true, "subagentToolkit": "undocumented", "backgroundDispatch": "undocumented" }, + "modelMode": "passive", + "hookBus": "host", + "stateIO": "filesystem", + "transport": "mcp", + "runtime": "go" + } } } diff --git a/capabilities/augment/capability.json b/capabilities/augment/capability.json index 5fa6e875f..27f211790 100644 --- a/capabilities/augment/capability.json +++ b/capabilities/augment/capability.json @@ -17,6 +17,7 @@ "AUGMENT_CONFIG_DIR" ] }, + "localConfigDir": ".augment", "configFormat": "settings-json", "artifactLayout": { "global": [ @@ -35,6 +36,14 @@ "nesting": "nested", "recursive": false, "converter": "convertClaudeCommandToAugmentSkill" + }, + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": "convertClaudeAgentToAugmentAgent" } ], "local": [ @@ -53,6 +62,14 @@ "nesting": "nested", "recursive": false, "converter": "convertClaudeCommandToAugmentSkill" + }, + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": "convertClaudeAgentToAugmentAgent" } ] }, @@ -64,6 +81,16 @@ "installSurface": "settings-json", "writesSharedSettings": true, "permissionWriter": null, - "extendedHookEvents": [] + "extendedHookEvents": [], + "hostIntegration": { + "embeddingMode": "declarative", + "commandSurface": "slash-file", + "dispatch": { "namedDispatch": true, "nested": "undocumented", "maxDepth": "undocumented", "background": true, "subagentToolkit": "full", "backgroundDispatch": "undocumented" }, + "modelMode": "passive", + "hookBus": "host", + "stateIO": "filesystem", + "transport": "mcp", + "runtime": "node" + } } } diff --git a/capabilities/claude/capability.json b/capabilities/claude/capability.json index f2f12751c..712b332f1 100644 --- a/capabilities/claude/capability.json +++ b/capabilities/claude/capability.json @@ -17,6 +17,7 @@ "CLAUDE_CONFIG_DIR" ] }, + "localConfigDir": ".claude", "configFormat": "settings-json", "artifactLayout": { "global": [ @@ -61,6 +62,16 @@ "Stop", "PreCompact", "FileChanged" - ] + ], + "hostIntegration": { + "embeddingMode": "imperative", + "commandSurface": "slash-file", + "dispatch": { "namedDispatch": true, "nested": true, "maxDepth": 5, "background": true, "subagentToolkit": "full", "backgroundDispatch": false }, + "modelMode": "passive", + "hookBus": "host", + "stateIO": "filesystem", + "transport": "mcp", + "runtime": "node" + } } } diff --git a/capabilities/cline/capability.json b/capabilities/cline/capability.json index 1c0d85403..119269871 100644 --- a/capabilities/cline/capability.json +++ b/capabilities/cline/capability.json @@ -17,6 +17,7 @@ "CLINE_CONFIG_DIR" ] }, + "localConfigDir": ".cline", "configFormat": "markdown-dir", "artifactLayout": { "global": [ @@ -38,6 +39,16 @@ "installSurface": "cline-rules", "writesSharedSettings": false, "permissionWriter": null, - "extendedHookEvents": [] + "extendedHookEvents": [], + "hostIntegration": { + "embeddingMode": "imperative", + "commandSurface": "slash-file", + "dispatch": { "namedDispatch": true, "nested": false, "maxDepth": 1, "background": true, "subagentToolkit": "read-only", "backgroundDispatch": false }, + "modelMode": "active", + "hookBus": "host", + "stateIO": "filesystem", + "transport": "mcp", + "runtime": "node" + } } } diff --git a/capabilities/codebuddy/capability.json b/capabilities/codebuddy/capability.json index 76538c71f..f2de29607 100644 --- a/capabilities/codebuddy/capability.json +++ b/capabilities/codebuddy/capability.json @@ -17,6 +17,7 @@ "CODEBUDDY_CONFIG_DIR" ] }, + "localConfigDir": ".codebuddy", "configFormat": "settings-json", "artifactLayout": { "global": [ @@ -35,6 +36,14 @@ "nesting": "flat", "recursive": false, "converter": "convertClaudeCommandToCodebuddySkill" + }, + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": "convertClaudeAgentToCodebuddyAgent" } ], "local": [ @@ -53,6 +62,14 @@ "nesting": "flat", "recursive": false, "converter": "convertClaudeCommandToCodebuddySkill" + }, + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": "convertClaudeAgentToCodebuddyAgent" } ] }, @@ -64,6 +81,16 @@ "installSurface": "settings-json", "writesSharedSettings": true, "permissionWriter": null, - "extendedHookEvents": [] + "extendedHookEvents": [], + "hostIntegration": { + "embeddingMode": "declarative", + "commandSurface": "slash-file", + "dispatch": { "namedDispatch": true, "nested": false, "maxDepth": 1, "background": true, "subagentToolkit": "full", "backgroundDispatch": false }, + "modelMode": "passive", + "hookBus": "host", + "stateIO": "filesystem", + "transport": "mcp", + "runtime": "node" + } } } diff --git a/capabilities/codex/capability.json b/capabilities/codex/capability.json index 893b9afeb..a1ddc49b7 100644 --- a/capabilities/codex/capability.json +++ b/capabilities/codex/capability.json @@ -17,6 +17,7 @@ "CODEX_HOME" ] }, + "localConfigDir": ".codex", "configFormat": "toml", "artifactLayout": { "global": [ @@ -48,6 +49,16 @@ "installSurface": "codex-toml", "writesSharedSettings": false, "permissionWriter": null, - "extendedHookEvents": [] + "extendedHookEvents": [], + "hostIntegration": { + "embeddingMode": "declarative", + "commandSurface": "slash-file", + "dispatch": { "namedDispatch": true, "nested": true, "maxDepth": 1, "background": true, "subagentToolkit": "full", "backgroundDispatch": true }, + "modelMode": "passive", + "hookBus": "host", + "stateIO": "filesystem", + "transport": "mcp", + "runtime": "node" + } } } diff --git a/capabilities/copilot/capability.json b/capabilities/copilot/capability.json index 73af7b196..be769009b 100644 --- a/capabilities/copilot/capability.json +++ b/capabilities/copilot/capability.json @@ -18,6 +18,7 @@ "COPILOT_HOME" ] }, + "localConfigDir": ".github", "configFormat": "markdown", "artifactLayout": { "global": [ @@ -48,6 +49,16 @@ "installSurface": "copilot-instructions", "writesSharedSettings": false, "permissionWriter": null, - "extendedHookEvents": [] + "extendedHookEvents": [], + "hostIntegration": { + "embeddingMode": "declarative", + "commandSurface": "slash-file", + "dispatch": { "namedDispatch": true, "nested": false, "maxDepth": 1, "background": true, "subagentToolkit": "full", "backgroundDispatch": false }, + "modelMode": "passive", + "hookBus": "host", + "stateIO": "filesystem", + "transport": "mcp", + "runtime": "undocumented" + } } } diff --git a/capabilities/cursor/capability.json b/capabilities/cursor/capability.json index cea99308c..247674009 100644 --- a/capabilities/cursor/capability.json +++ b/capabilities/cursor/capability.json @@ -17,6 +17,7 @@ "CURSOR_CONFIG_DIR" ] }, + "localConfigDir": ".cursor", "configFormat": "none", "artifactLayout": { "global": [ @@ -35,6 +36,14 @@ "nesting": "flat", "recursive": false, "converter": "convertClaudeCommandToCursorCommand" + }, + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": "convertClaudeAgentToCursorAgent" } ], "local": [ @@ -53,6 +62,14 @@ "nesting": "flat", "recursive": false, "converter": "convertClaudeCommandToCursorCommand" + }, + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": "convertClaudeAgentToCursorAgent" } ] }, @@ -64,6 +81,16 @@ "installSurface": "cursor-hooks-json", "writesSharedSettings": false, "permissionWriter": null, - "extendedHookEvents": [] + "extendedHookEvents": [], + "hostIntegration": { + "embeddingMode": "imperative", + "commandSurface": "slash-file", + "dispatch": { "namedDispatch": true, "nested": true, "maxDepth": 2, "background": true, "subagentToolkit": "full", "backgroundDispatch": true }, + "modelMode": "passive", + "hookBus": "host", + "stateIO": "filesystem", + "transport": "mcp", + "runtime": "node" + } } } diff --git a/capabilities/gemini/capability.json b/capabilities/gemini/capability.json index 65cd8aaff..ccc67a8d1 100644 --- a/capabilities/gemini/capability.json +++ b/capabilities/gemini/capability.json @@ -17,6 +17,7 @@ "GEMINI_CONFIG_DIR" ] }, + "localConfigDir": ".gemini", "configFormat": "settings-json", "artifactLayout": { "global": [ @@ -52,6 +53,16 @@ "BeforeAgent", "AfterAgent", "BeforeModel" - ] + ], + "hostIntegration": { + "embeddingMode": "declarative", + "commandSurface": "slash-toml", + "dispatch": { "namedDispatch": true, "nested": false, "maxDepth": 1, "background": "undocumented", "subagentToolkit": "undocumented", "backgroundDispatch": false }, + "modelMode": "passive", + "hookBus": "host", + "stateIO": "filesystem", + "transport": "mcp", + "runtime": "node" + } } } diff --git a/capabilities/hermes/capability.json b/capabilities/hermes/capability.json index c2f861741..5dd4e4b05 100644 --- a/capabilities/hermes/capability.json +++ b/capabilities/hermes/capability.json @@ -17,6 +17,7 @@ "HERMES_HOME" ] }, + "localConfigDir": ".hermes", "configFormat": "settings-json", "artifactLayout": { "global": [ @@ -48,6 +49,16 @@ "installSurface": "settings-json", "writesSharedSettings": true, "permissionWriter": null, - "extendedHookEvents": [] + "extendedHookEvents": [], + "hostIntegration": { + "embeddingMode": "imperative", + "commandSurface": "slash-programmatic", + "dispatch": { "namedDispatch": false, "nested": true, "maxDepth": 1, "background": true, "subagentToolkit": "read-only", "backgroundDispatch": false }, + "modelMode": "active", + "hookBus": "host", + "stateIO": "filesystem", + "transport": "mcp", + "runtime": "python" + } } } diff --git a/capabilities/kilo/capability.json b/capabilities/kilo/capability.json index 59a1d6899..032f9be2e 100644 --- a/capabilities/kilo/capability.json +++ b/capabilities/kilo/capability.json @@ -24,6 +24,7 @@ "env": [] } }, + "localConfigDir": ".kilo", "configFormat": "settings-json", "artifactLayout": { "global": [ @@ -70,6 +71,16 @@ "installSurface": "settings-json", "writesSharedSettings": false, "permissionWriter": "kilo", - "extendedHookEvents": [] + "extendedHookEvents": [], + "hostIntegration": { + "embeddingMode": "imperative", + "commandSurface": "slash-file", + "dispatch": { "namedDispatch": true, "nested": true, "maxDepth": -1, "background": true, "subagentToolkit": "undocumented", "backgroundDispatch": false }, + "modelMode": "active", + "hookBus": "host", + "stateIO": "filesystem", + "transport": "mcp", + "runtime": "bun" + } } } diff --git a/capabilities/kimi/capability.json b/capabilities/kimi/capability.json index a4e74e4e3..d9bf8da7d 100644 --- a/capabilities/kimi/capability.json +++ b/capabilities/kimi/capability.json @@ -22,6 +22,7 @@ ], "probeExists": "skills" }, + "localConfigDir": ".kimi-code", "configFormat": "none", "artifactLayout": { "global": [ @@ -51,6 +52,16 @@ "installSurface": "profile-marker-only", "writesSharedSettings": false, "permissionWriter": null, - "extendedHookEvents": [] + "extendedHookEvents": [], + "hostIntegration": { + "embeddingMode": "imperative", + "commandSurface": "slash-file", + "dispatch": { "namedDispatch": true, "nested": false, "maxDepth": 1, "background": true, "subagentToolkit": "undocumented", "backgroundDispatch": false }, + "modelMode": "passive", + "hookBus": "host", + "stateIO": "filesystem", + "transport": "mcp", + "runtime": "python" + } } } diff --git a/capabilities/opencode/capability.json b/capabilities/opencode/capability.json index 1ad177037..21987141b 100644 --- a/capabilities/opencode/capability.json +++ b/capabilities/opencode/capability.json @@ -19,6 +19,7 @@ "XDG_CONFIG_HOME" ] }, + "localConfigDir": ".opencode", "configFormat": "settings-json", "artifactLayout": { "global": [ @@ -65,6 +66,16 @@ "installSurface": "settings-json", "writesSharedSettings": true, "permissionWriter": "opencode", - "extendedHookEvents": [] + "extendedHookEvents": [], + "hostIntegration": { + "embeddingMode": "imperative", + "commandSurface": "slash-file", + "dispatch": { "namedDispatch": true, "nested": "undocumented", "maxDepth": "undocumented", "background": false, "subagentToolkit": "full", "backgroundDispatch": "undocumented" }, + "modelMode": "active", + "hookBus": "host", + "stateIO": "filesystem", + "transport": "mcp", + "runtime": "bun" + } } } diff --git a/capabilities/qwen/capability.json b/capabilities/qwen/capability.json index 9da38ac16..2199c8a5e 100644 --- a/capabilities/qwen/capability.json +++ b/capabilities/qwen/capability.json @@ -17,6 +17,7 @@ "QWEN_CONFIG_DIR" ] }, + "localConfigDir": ".qwen", "configFormat": "settings-json", "artifactLayout": { "global": [ @@ -52,6 +53,16 @@ "SubagentStop", "Stop", "PreCompact" - ] + ], + "hostIntegration": { + "embeddingMode": "imperative", + "commandSurface": "slash-file", + "dispatch": { "namedDispatch": true, "nested": false, "maxDepth": 1, "background": true, "subagentToolkit": "full", "backgroundDispatch": false }, + "modelMode": "passive", + "hookBus": "host", + "stateIO": "filesystem", + "transport": "mcp", + "runtime": "node" + } } } diff --git a/capabilities/trae/capability.json b/capabilities/trae/capability.json index 3713c8300..87dc2f5cd 100644 --- a/capabilities/trae/capability.json +++ b/capabilities/trae/capability.json @@ -17,6 +17,7 @@ "TRAE_CONFIG_DIR" ] }, + "localConfigDir": ".trae", "configFormat": "none", "artifactLayout": { "global": [ @@ -27,6 +28,14 @@ "nesting": "nested", "recursive": false, "converter": "convertClaudeCommandToTraeSkill" + }, + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": "convertClaudeAgentToTraeAgent" } ], "local": [ @@ -37,6 +46,14 @@ "nesting": "nested", "recursive": false, "converter": "convertClaudeCommandToTraeSkill" + }, + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": "convertClaudeAgentToTraeAgent" } ] }, @@ -47,6 +64,16 @@ "installSurface": "profile-marker-only", "writesSharedSettings": false, "permissionWriter": null, - "extendedHookEvents": [] + "extendedHookEvents": [], + "hostIntegration": { + "embeddingMode": "imperative", + "commandSurface": "slash-file", + "dispatch": { "namedDispatch": true, "nested": "undocumented", "maxDepth": "undocumented", "background": true, "subagentToolkit": "undocumented", "backgroundDispatch": "undocumented" }, + "modelMode": "passive", + "hookBus": "engine", + "stateIO": "filesystem", + "transport": "mcp", + "runtime": "node" + } } } diff --git a/capabilities/windsurf/capability.json b/capabilities/windsurf/capability.json index 293e18b5f..98933d374 100644 --- a/capabilities/windsurf/capability.json +++ b/capabilities/windsurf/capability.json @@ -18,9 +18,19 @@ "WINDSURF_CONFIG_DIR" ] }, + "localConfigDir": ".windsurf", "configFormat": "none", "artifactLayout": { - "global": [], + "global": [ + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": "convertClaudeAgentToWindsurfAgent" + } + ], "local": [ { "kind": "commands", @@ -29,6 +39,14 @@ "nesting": "flat", "recursive": false, "converter": "convertClaudeCommandToWindsurfWorkflow" + }, + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": "convertClaudeAgentToWindsurfAgent" } ] }, @@ -39,6 +57,16 @@ "installSurface": "profile-marker-only", "writesSharedSettings": false, "permissionWriter": null, - "extendedHookEvents": [] + "extendedHookEvents": [], + "hostIntegration": { + "embeddingMode": "declarative", + "commandSurface": "slash-file", + "dispatch": { "namedDispatch": "undocumented", "nested": "undocumented", "maxDepth": "undocumented", "background": "undocumented", "subagentToolkit": "undocumented", "backgroundDispatch": "undocumented" }, + "modelMode": "passive", + "hookBus": "host", + "stateIO": "filesystem", + "transport": "mcp", + "runtime": "undocumented" + } } } diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 61245e35e..d5296bf0d 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -299,6 +299,7 @@ "check-command-router.cjs", "cjs-command-router-adapter.cjs", "cli-exit.cjs", + "cli-skew-check.cjs", "clock.cjs", "clusters.cjs", "code-review-flags.cjs", @@ -329,8 +330,10 @@ "graphify-command-router.cjs", "graphify.cjs", "gsd2-import.cjs", + "host-integration.cjs", "init-command-router.cjs", "init.cjs", + "install-engine.cjs", "install-profiles.cjs", "installer-migration-authoring.cjs", "installer-migration-report.cjs", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 1474fb47d..e9b6f3c87 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -439,8 +439,10 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `graphify.cjs` | Knowledge-graph build/query/status/diff for `/gsd-graphify` | | `graphify-command-router.cjs` | ADR-959 capability command router for `gsd-tools graphify` — dispatches build/query/status/diff subcommands; first real capability command cutover (phase 4d-impl-2) | | `gsd2-import.cjs` | External-plan ingest for `/gsd-import --from-gsd2` | +| `host-integration.cjs` | Host-Integration Interface (ADR-1239 Phase A) — negotiated capability contract over the six host-integration points; `negotiateHostCapabilities` fail-closes on undeclared/unknown/`undocumented` values, typed degradation ladder, host-capability profiles; the 8 `runtime.hostIntegration` axes are validated in `capability-validator.cjs` and sourced per-CLI in `docs/reference/host-integration-capability-matrix.md` | | `init-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools init` | | `init.cjs` | Compound context loading for each workflow type | +| `install-engine.cjs` | Runtime-artifact install engine — `installRuntimeArtifacts`/`uninstallRuntimeArtifacts`/`installOpencodeFamilySkills` + their helpers, extracted from `bin/install.js` (ADR-1239 Phase B, #1679); install.js imports them back and injects `getCommitAttribution` | | `install-profiles.cjs` | Install profile allowlist + skill staging for `--minimal` install (#2762); single source of truth for which `gsd-*` skills/agents land in runtime config dirs | | `installer-migration-authoring.cjs` | Installer migration authoring guardrails for record metadata, explicit scopes, ownership evidence, and runtime contract citations | | `installer-migration-report.cjs` | Installer migration report projection and blocked-action guard for install/update integration | diff --git a/docs/README.md b/docs/README.md index 509434581..f05fce2cd 100644 --- a/docs/README.md +++ b/docs/README.md @@ -36,6 +36,7 @@ Language versions: [English](README.md) · [Português (pt-BR)](pt-BR/README.md) - [Spike and sketch](how-to/spike-and-sketch.md) — use `/gsd-spike` and `/gsd-sketch` for exploratory work before committing to a plan - [Design a UI phase](how-to/design-a-ui-phase.md) — use the UI phase loop for frontend and visual work - [Develop a Capability for GSD 1.5+](how-to/develop-a-capability.md) — add feature Capabilities, hook fragments, and registry entries +- [Add or update a host's integration](how-to/add-or-update-a-host-integration.md) — set a host's documentation-sourced `runtime.hostIntegration` axes (ADR-1239 Phase A), with the `undocumented` sentinel rule - [Turn a capability off (and keep it off)](how-to/turn-a-capability-off.md) — disable a capability via the surface, or gate individual hooks off without removing the capability - [Drive GSD from a tracker issue](how-to/drive-gsd-from-a-tracker-issue.md) — start a phase from a GitHub, Linear, or Jira issue - [Migrate from GSD 2](how-to/migrate-from-gsd-2.md) — upgrade an existing GSD 2 project to GSD Core diff --git a/docs/adr/1239-gsd-embeddable-orchestration-engine.md b/docs/adr/1239-gsd-embeddable-orchestration-engine.md index e24a95d82..5ba27d05d 100644 --- a/docs/adr/1239-gsd-embeddable-orchestration-engine.md +++ b/docs/adr/1239-gsd-embeddable-orchestration-engine.md @@ -85,6 +85,20 @@ The **primitive vocabulary stays closed and first-party** (ADR-857 Decision 8): Each phase is its own `approved-*` issue + PR with equivalence/parity proof. +### Amendment — Phase A implemented (#1684, v1.7.0) + +Phase A is **implemented** (the ADR itself remains `Proposed` overall until Phases B–E land). The negotiated capability schema is materialized as a pure, additive, no-I/O module — the **Host-Integration Interface** (`src/host-integration.cts` → `gsd-core/bin/lib/host-integration.cjs`): + +- **The eight negotiated axes** are carried under `capability.json` `runtime.hostIntegration` (extending, not replacing, the ADR-1016 axes), validated by `validateRuntimeBody` (`capability-validator.cjs`) across all 16 runtime descriptors, with the closed vocabulary kept in lock-step by a parity guard. +- **`PROTOCOL_VERSION`** is an integer starting at `1`, **distinct** from the package `version` / `engines.gsd` semver (the `version`/`protocolVersion` overlap, resolved). +- **`negotiateHostCapabilities(host, engine?)`** performs the in-process `initialize` exchange and enforces the trust-boundary invariant `effective ⊆ host-declared ∩ engine-known`: an undeclared axis or an unknown / higher-`protocolVersion` value is **never** trusted — it degrades to the most-restrictive known value (fail-closed), never throws. +- **`degradationFor`** is the typed Full/Degraded/Absent ladder table; **`profileOf` + `PROFILE_BASELINES`** classify each descriptor into `programmatic-cli` (9 hosts: claude, opencode, cursor, cline, hermes, qwen, kilo, trae, kimi), `declarative-cli` (7 hosts: codex, gemini, antigravity, augment, codebuddy, copilot, windsurf), or `ide` (defined as a baseline; no installed host yet — VS Code lands in Phase D). +- **Overlap resolutions (explicit):** `commandStyle` (GSD emission style, retained) ⊥ `commandSurface` (host surface type); `hookEvents` dialect ⊥ `hookBus` ownership (a host with `hooksSurface:none` may still be `hookBus:host` — e.g. opencode); the `opencode-subset` `hookEvents` value remains reserved for the Phase D OpenCode hook-dialect consumer; `runtimeCompat` (feature→host) stays an independent override, orthogonal to these runtime→engine axes. + +**Every per-host axis value is documentation-sourced, with citations.** Each of the 8 axes for all 16 installed CLIs was determined from that CLI's authoritative documentation (Context7 + the official dev docs/source), never inferred. The full per-CLI, per-axis matrix — value, source, and an evidence quote — is recorded in [`docs/reference/host-integration-capability-matrix.md`](reference/host-integration-capability-matrix.md), the deployment source-of-truth that Phases B–E build on. Where a CLI's docs genuinely do not state an axis, the descriptor carries the explicit `undocumented` sentinel (which `negotiateHostCapabilities` fail-closes on) rather than a guessed value — 22 such markers exist today, each with its search trail in the matrix. Two findings corrected this ADR's original appendix matrix: (1) current OpenAI **Codex** docs document slash-commands, so its `commandSurface` is `slash-file`, not `prose-only`; (2) several hosts run non-Node runtimes (opencode & kilo on **bun**; hermes & kimi on **python**; antigravity on **go**), so the `runtime` axis vocabulary was widened to `node|bun|sandboxed-web|python|go|rust|electron|other`. The documented `embeddingMode` split (9 imperative / 7 declarative, above) likewise reflects each CLI's real plugin/extension API, not a profile assumption. + +No consumer wires the negotiated result yet — Phase A is interface-definition only; the engine↔host boundary (Phase B) and the adapters (Phase C) are where it is consumed. + ## Host-capability profiles (negotiation baselines) - **Programmatic-CLI** (Claude Code, pi, OpenCode): imperative; full dispatch; host hook bus; MCP; `slash` surface. The richest target — minimal degradation. diff --git a/docs/adr/1606-prohibition-enforcement-verify-seam.md b/docs/adr/1606-prohibition-enforcement-verify-seam.md new file mode 100644 index 000000000..028735507 --- /dev/null +++ b/docs/adr/1606-prohibition-enforcement-verify-seam.md @@ -0,0 +1,189 @@ +# ADR 1606: prohibition-enforcement verify-time seam [Proposed] + +- **Status:** Proposed (consolidation ADR — see "Relationship to ADR-550") +- **Date:** 2026-06-22 + +> **Provenance.** Drafted 2026-06-22 to promote a decision that accreted as **four** +> chronological addendum blocks on ADR-550 (the 2026-06-12 test-tier disposition note that +> folded in #1259, then #1279, #1346, and #1278) into a single, first-class architecture-of- +> record for the verify-time enforcement subsystem. Authored by the #644/#1259 implementer. +> The *area* (why prohibitions are first-class at all) traces to the author's +> prohibition-elicitation (N18) and verifier-abstention (N17) findings — *the author's own +> research*, cited as motivation, not claimed as a novel GSD contribution; the *enforcement +> mechanism* in this ADR is motivated specifically by closing the caller-attestation fake- +> green hole. Verified against `next` + `src/prohibition-enforcement.cts` (≈43KB) and +> `gsd-core/references/prohibition-probe.md`. + +## Relationship to ADR-550 (read this first) + +ADR-550 is the **spec-phase probe contract**: what a prohibition *is*, how it is +represented (`SPEC.md` acceptance criterion ↔ `must_haves.prohibitions:`), and how it is +*tiered* (`test` vs `judgment`, Decisions 3–7). That ownership is unchanged. + +This ADR carves out and consolidates the **verify-time enforcement mechanism** for the +`test` tier — the `check prohibition-enforcement` producer in +`src/prohibition-enforcement.cts` — which ADR-550 only documents as a chain of dated +addenda. The boundary: + +| Concern | Owner | +|---------|-------| +| Prohibition representation, tiering, spec→plan projection | **ADR-550** (D3, D7) | +| Judgment-tier soft-gate / "never a silent pass" policy | **ADR-550** (D4) | +| Test-tier *enforcement producer* (locate → prove-fail-first → run → dispose) | **this ADR** | +| Capability/core-rail placement of the verifier↔predicate contract | **ADR-857** *"Verification substrate vs. plug-in tier (the predicate boundary)"* (decision #6), referenced by both | + +**Dedup proposal (decide at PR review):** on accepting this ADR, replace ADR-550's +2026-06-12 / #1259 / #1279 / #1346 / #1278 enforcement addenda with a one-line pointer to +this ADR, leaving 550 to own the contract and this ADR to own the mechanism. Until that is +agreed, 550's addenda remain authoritative and this ADR is non-binding. + +## Context + +ADR-550 D4 originally specified the `test` tier as a "hard gate in both interactive and +autonomous modes" but left the *mechanism* unspecified. As the prohibition probe shipped +(#644) and grew an enforcement producer (#1259→#1346), a set of load-bearing decisions had +to be made that are not derivable from the contract alone: + +- A generic producer cannot *trust* that a wired check actually fails on a violation. Caller + attestation (`failFirst: true`) is unfalsifiable at verify time and was a fake-green hole. +- A generic producer cannot *synthesize* a violation for an arbitrary check, so proving + fail-first requires an author-supplied known-bad subject. +- "The test went red" is not proof the *content* caused the red — an env-var-triggered or + load-crash red forges a green. +- The check descriptor must round-trip through the **flat** `parseMustHavesBlock` shared by + `truths`/`artifacts`/`key_links` without a parser rewrite that would regress those readers. +- A missing, partial, or un-provable check must **fail closed**, never silently pass — the + whole point of the tier. + +## Decision + +1. **No path greens on attestation alone.** A `test`-tier prohibition reaches + `passed`/`green` **only** when its wired check (a) genuinely, **non-vacuously** runs and + passes AND (b) is independently **machine-proven fail-first** against a known violation. + The enforcement producer (`runProhibitionEnforcement` in + `src/prohibition-enforcement.cts`) computes this as + `passed = proof.provenFailFirst === true && run.passed === true` and only then emits + non-empty `enforcementEvidence`. The disposition step + (`dispositionForProhibition` in `src/probe-core.cts`) then greens a `test`-tier item + **only** on that non-empty evidence; every other outcome — missing check, partial/invalid + descriptor, can't-prove, throws, times out, no violation source, passes-on-violation, + `located:false` — yields `{status:'unverified', flagged:true}` (`gaps_found`, never green) + in both interactive and autonomous modes. (The green rule and the fail-closed default are + intentionally split across the producer and the disposition so the disposition can fail + closed even when the producer never ran.) This is the standing form of ADR-550 D4's + guarantee. + +2. **Two wired-check kinds, one producer.** The producer accepts exactly two mechanisms: + - **`node-test`** — a `node --test` negative test that reports a real, **non-vacuous** + failing test. Two distinct vacuity guards apply: `isNonVacuousNodeTestRed` rejects a + load-crash red by requiring a failing test **named distinctly from the target file**; + `isNonVacuousNodeTestPass` rejects the empty-file "0 tests = pass" forgery on the + pass side. (Both live in `src/prohibition-enforcement.cts`.) + - **`lint-rule`** — a lint/AST rule run through the project flat config as + `eslint --format json` and filtered by `ruleId` (so `local/*` plugin rules load; a bare + `--rule` cannot). Invoked via `process.execPath` against the resolved eslint CLI, not a + bare `eslint` binary. Dogfooded on the in-tree `local/no-source-grep` rule. + +3. **Machine-proven fail-first via an author-supplied violation fixture.** Before a clean + pass can green, the producer runs the wired check against a **known-bad subject** and + requires RED. The subject is sourced from `violationFixture`: + - `lint-rule`: a file whose content violates `rule`; the rule id must appear in the JSON + report (the rule must have teeth). + - `node-test`: injected into the child as `GSD_PROHIB_SUBJECT=`; the + negative test reads that env var to locate its subject and must go RED against it. + - **Absent fixture → fail closed** (never attestation). Existence is checked + (`fs.existsSync(path.resolve(cwd, fixture))`) before spawning, symmetric with the + lint-rule path which fail-closes on a `< 1`-file result. + +4. **Causation control (opt-in).** A node-test red proves nothing about *why* it is red. An + optional `cleanFixture` runs the same negative test a second time with + `GSD_PROHIB_SUBJECT=` and requires **non-vacuous GREEN** + (`isNonVacuousNodeTestPass`) — so fail-first is proven only when the check is **RED on the + violation AND GREEN on the clean subject** (content-dependent red). Opt-in, not mandatory: + absent `cleanFixture`, behaviour matches the pre-#1346 zero-authoring compose path and the + "reds because the env var is set" case stays a documented residual for that one author's + check. The lint-rule kind needs no analog (its subject *is* the linted file; no env-var + indirection). + +5. **Deterministic locate via five flat scalars — never a nested object.** The wired-check + descriptor is authored at spec-phase and projected onto the `must_haves.prohibitions` + item as **five flat scalar keys**: `check_kind`, `check_target`, `check_rule` (lint-rule + only), `check_violation_fixture`, `check_clean_fixture` (optional). A nested `check: {}` + object is **rejected**: `parseMustHavesBlock` is a flat parser and its + `reconstructFrontmatter` serializer is lossy for nested object-lists, so a nested shape + would mangle the round-trip and risk the shared `truths`/`artifacts`/`key_links` readers. + `projectProhibitions` (in `src/probe-core.cts`) emits the scalars only for a well-formed + descriptor; `descriptorFromProjection` (in `src/prohibition-enforcement.cts`) reads them + back into the `{ kind, target, rule? }` `CheckDescriptor`. A prohibition authored with all + five scalars machine-proves fail-first and greens **end-to-end through the projection with + zero hand-authoring**. + +6. **`failFirst` is demoted, not removed (FF-08).** The `CheckDescriptor.failFirst` field is + kept for route-JSON backward-compat but **demoted to a non-authoritative hint** — the + machine prover supersedes it. Removal was rejected (breaks the route-JSON shape mid- + migration); demote-and-ignore satisfies "no path greens on attestation." + +7. **The contract is the CI surface, not the LLM.** Per ADR-550 D5, CI deterministically + tests parse/validate, the projection round-trip (fast-check property + CHK-03), the + fail-closed guards (CHK-06), and backward-compat (CHK-07) — never the model's judgment. + This ADR adds no CI claim over LLM behaviour. + +## Consequences + +- **Positive:** the verify-time enforcement subsystem has a single architecture-of-record + instead of five addenda on a spec-phase ADR; the "never a silent pass" guarantee is stated + once, completely, with its fail-closed defaults; the descriptor's flat-scalar shape and its + rationale are findable without reading the frontmatter parser. +- **Costs:** one more ADR to keep in sync with `src/prohibition-enforcement.cts`; the dedup + against ADR-550's addenda must actually be executed at PR time or the repo carries two + homes for the same decision (the explicit risk this ADR is meant to *remove*). +- **Open conventions (renamable at review, zero live consumers):** `GSD_PROHIB_SUBJECT` and + the `check_violation_fixture`/`check_clean_fixture` scalars have **no in-tree `node-test` + consumer** yet (the only live dogfood is the lint-rule `local/no-source-grep`; node-test + fail-first is exercised only by synthetic temp fixtures). A rename or an argv-for-env-var + swap is a mechanical zero-migration find/replace — surfaced here for the maintainer to + settle at review, exactly as #1278/#1279 were. (Hyrum's Law: this ADR deliberately marks + them as not-yet-depended-on so they remain changeable; the *scalar key names emitted by + `projectProhibitions` in shipped code* are, by contrast, already a contract.) +- **Boundary held:** canon security/compliance is referred to `/gsd:secure-phase` + eslint, + not minted here (ADR-550 D6); this ADR governs only bespoke product/values test-tier + enforcement. + +## Alternatives considered (rejected & deferred) + +Enforcement-side alternatives, each with the standing reason and a re-open condition. *(The +recall/representation/packaging-side alternatives — the withdrawn LLM classifier #652, a +deterministic recall engine, a `polarity` field on `truths`, and the deferred dispatcher CLI — +belong to the spec-phase contract and are recorded in ADR-550's "Alternatives considered.")* + +- **A nested `check: {}` descriptor object — REJECTED.** `parseMustHavesBlock` is a flat + parser and `reconstructFrontmatter` is lossy for nested object-lists, so a nested shape + would mangle the round-trip and could regress the shared `truths`/`artifacts`/`key_links` + readers. Hence the five **flat scalar** keys (Decision 5). *Re-open only if* + `parseMustHavesBlock` is replaced with a structured parser (its own ADR, with the full + shared-reader regression surface). +- **An inline producer-written violation snippet — REJECTED.** To machine-prove fail-first the + violation is an author-supplied **fixture path** (`check_violation_fixture`), not source the + producer writes inline, which would bake rule-specific source into a generic producer + (Decision 3). +- **`failFirst` caller attestation as authoritative — REJECTED → DEMOTED (FF-08).** Trusting + the caller's `failFirst: true` was an unfalsifiable fake-green hole; the machine prover + supersedes it and the field is demoted to a non-authoritative hint kept only for route-JSON + backward-compat (Decision 6). Outright removal was also rejected (breaks the route-JSON + shape mid-migration). +- **Mandatory causation control — REJECTED in favour of opt-in.** Requiring every node-test + prohibition to ship a `cleanFixture` would regress the zero-authoring compose path and + hard-gate every existing descriptor without one; the control is opt-in (Decision 4), leaving + one documented residual rather than breaking working checks. + +## Cross-references + +- **ADR-550** — spec-phase probe contract; this ADR consolidates its enforcement addenda, and + ADR-550 holds the recall/representation/packaging-side rejected alternatives. +- **ADR-857** — section *"Verification substrate vs. plug-in tier (the predicate boundary)"* + (decision #6): the verifier↔predicate contract lands on the **core verify rail** + (non-toggleable), never in `capabilities/`; this seam is its concrete enforcement instance. +- **`gsd-core/references/prohibition-probe.md`** — the portable runtime reference. +- **`docs/how-to/resolve-prohibition-findings.md`** — user-facing resolution guide. +- Code: `src/prohibition-enforcement.cts`, `src/probe-core.cts` (`projectProhibitions`), + `gsd-core/workflows/verify-phase.md`. Issues: #644, #1259, #1278, #1279, #1346. diff --git a/docs/adr/1703-portability-enforcement-architecture.md b/docs/adr/1703-portability-enforcement-architecture.md new file mode 100644 index 000000000..986a5c73c --- /dev/null +++ b/docs/adr/1703-portability-enforcement-architecture.md @@ -0,0 +1,246 @@ +# ADR-1703: Cross-platform portability enforcement as AST ESLint rules + +- **Status:** Accepted +- **Date:** 2026-06-25 (Phase 0); **Accepted 2026-06-26** (Phase 7 closeout — all phases shipped) +- **Issue:** [#1703](https://github.com/open-gsd/gsd-core/issues/1703) — Phase 0 of epic [#1702](https://github.com/open-gsd/gsd-core/issues/1702) +- **Supersedes:** the regex-based `scripts/lint-windows-test-portability.cjs`, the + `tests/windows-test-parity-guard.test.cjs` named-set ratchet (G1–G6), the + `// windows-portability-ok:` comment convention, and `scripts/lib/allowlist-ratchet.cjs` + usage for portability classes. + +## Context + +GSD must run correctly when installed and run on Windows (backslash paths, `C:\`, +`cmd`/PowerShell, no `/bin/sh`, DOS file modes, `\r\n`), not just macOS/Linux. `CONTEXT.md` +documents a `DEFECT.WINDOWS-*` taxonomy of failure shapes that recur and ship to the +`windows-latest` CI lane undetected because the local `gsd-test` gate is Mac/Linux only. + +Enforcement accreted as **three incompatible, hand-rolled mechanisms**: + +1. **`scripts/lint-windows-test-portability.cjs`** — today a narrow regex *tripwire* for the + chmod exec-bit + `sh`/`bash -c` shape, with a `windows-portability-ok` opt-out matched against + the whole source. This epic was seeded (#1694) by an attempt to *extend* this script to the + path-literal-in-assert shape; adversarial review of that extension found a regex that + *silently could not match `deepStrictEqual`*, loose normalizer recognition (false negatives), + and hand-rolled balanced-paren-splitting fragility — so the extension was **abandoned** in + favour of this redesign. That abortive attempt is the concrete demonstration that growing the + regex path is the wrong direction (Kernighan's Law, Greenspun's Tenth Rule); `CONTEXT.md` + still records the path-literal lint as "enhancement TBD". +2. **`tests/windows-test-parity-guard.test.cjs`** — a *ratchet*: a frozen `KNOWN_OFFENDERS` + allowlist (G1–G6) that grandfathers existing violations and only blocks *new* ones. It + institutionalizes the defects instead of removing them. +3. **`// windows-portability-ok:`** — a bespoke comment opt-out matched by a whole-source regex, + coarse enough that a single occurrence anywhere in a file can disable that file's check. + +This is three parsers, two escape conventions, and a permanent grandfather list — to do a job +that a linter does natively. + +## Decision + +Replace all three with a single coherent mechanism: **AST-based ESLint rules in the existing +`local/*` plugin** (`eslint-rules/`, registered in `eslint.config.mjs`; ESLint v9 flat config, +`RuleTester` available from `require('eslint')`). They use the parsers already in the stack: +**Espree** (ESLint's default, `sourceType: 'commonjs'`) for the test-file `.cjs` rules, and +**`@typescript-eslint/parser`** (already configured for `src/**/*.cts`) for the two production +`.cts` rules. Specifically: + +1. **AST, not regex.** Each portability check is an ESLint rule that matches real syntax nodes + (`CallExpression`, `MemberExpression`, `Literal`, `TemplateLiteral`), not text. Rules run + in-editor *and* in CI via the existing `eslint .` (invoked by `lint:ci` through `npm run + lint`) — strictly more coverage than the CI-only `node scripts/lint-windows-test-portability.cjs` + they replace. +2. **Hard-fail, no ratchet, no grandfathering.** There is no `KNOWN_OFFENDERS` allowlist. + Every existing and currently-grandfathered violation is **fixed**, not registered. +3. **Zero escape hatches.** No per-line `eslint-disable` is permitted for portability rules + (enforced — see "Strictness" below). Legitimately platform-specific code must be + *structured* so the rule recognizes it (e.g. guarded by `process.platform !== 'win32'`), + not annotated around. +4. **Single source of truth.** Shared vocabulary (the `PATH_RETURNING_FNS` set, mode-bit + octals, non-portable exec names) lives in one module `eslint-rules/lib/portability-vocab.cjs`, + consumed by every rule and guarded against drift by an AST completeness check. +5. **Tested with `RuleTester`.** Each rule ships an ESLint `RuleTester` suite of `valid`/ + `invalid` cases. Because `RuleTester` feeds fixtures to the rule directly (it does not scan + the test file), the self-flagging problem that forced the whole-file opt-out simply does not + exist — the opt-out hack is deleted, not reimplemented. + +### Rule catalog (maps 1:1 to `DEFECT.WINDOWS-*`) + +| Rule (`local/…`) | DEFECT (greppable in `CONTEXT.md`) | Surface | +|---|---|---| +| `no-path-literal-in-assert` | `DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT` | tests | +| `no-posix-mode-bit-assert` | `DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT` | tests | +| `no-unguarded-nonportable-exec` | `DEFECT.WINDOWS-TEST-PORTABILITY` (chmod+`sh -c`) + `DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE` | tests | +| `no-crlf-fragile-split` | `DEFECT.WINDOWS-TEST-PORTABILITY` (G1/G2/G3) + `DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE` | tests | +| `no-hardcoded-tmp` | `DEFECT.WINDOWS-TEST-PORTABILITY` (G4) | tests | +| `no-bare-npm-exec` | `DEFECT.WINDOWS-TEST-PORTABILITY` (G5) | tests | +| `require-userprofile-with-home` | `DEFECT.WINDOWS-TEST-PORTABILITY` (G6) | tests | +| `normalize-path-in-content` | `DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT` (`RULESET.CONTENT-PATH-NORMALIZATION`) | `src/**/*.cts` | +| `require-fs-op-fallback` | `DEFECT.WINDOWS-FS-OPS` | `src/**/*.cts`, build/install | + +**Taxonomy coverage.** This catalog addresses every `DEFECT.WINDOWS-*` class plus +`DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE` in `CONTEXT.md`, to the extent each is *statically* +detectable. `DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE` has two parts: (a) the CRLF / literal-`\n` +fence-match shape — covered by `no-crlf-fragile-split`; and (b) feeding a Windows `os.tmpdir()` +path into a Git Bash glob / `bash -c` — covered jointly by `no-hardcoded-tmp` (steer tmp usage) +and `no-unguarded-nonportable-exec` (require a platform guard on `bash -c`). The residual runtime +Git-Bash path-translation behavior is not fully statically decidable; the rules catch the source +shapes that produce it, not the runtime outcome. `DEFECT.WINDOWS-ARGV-OVERFLOW` is deliberately +**not** in this catalog: it is a *runtime* argv-length property (the args-array size is not +statically knowable — e.g. `execFileSync('node', [...N runtime paths])`), so no AST rule can +soundly detect it. Phase 3 evaluated a `no-oversized-test-argv` heuristic and **dropped it as +unsound** (it could only catch a contrived literal `.repeat(N)` command string, never the +canonical array overflow). The class is addressed at the source: the production `run-tests.cjs` +argv chunking under `RUN_TESTS_MAX_CMDLINE_CHARS`, with its anchor `tests/run-tests-harness.test.cjs`. + +### Architecture + +- **`eslint-rules/.cjs`** — one file per rule, matching the existing `local/*` rule + style. Each exports `{ meta, create }`. +- **`eslint-rules/lib/portability-vocab.cjs`** — the single source of truth: `PATH_RETURNING_FNS`, + mode-bit octal predicates, non-portable command names, normalizer-call recognizers. +- **`eslint-rules/lib/platform-guard.cjs`** — shared AST helper answering "is this node + *control-dependent* on a Windows platform condition?" (a dominator check, not a textual + mention). It MUST recognize the guard shapes that actually occur in the suite: + `process.platform !== 'win32'` / `=== 'win32'` (negated), `os.platform()`, a hoisted + `const isWindows = …` consumed by a later `if (!isWindows)`, early-return guards, nested `if` + blocks, and `node:test` skips (`t.skip()`, the `{ skip }` option / skip objects). The current + regex lint is unsound here — it treats a bare `const isWindows = …` as "guarded" without + requiring the dangerous call to be inside the branch; the AST helper fixes that by checking + control dependence. This is the precision backbone that makes zero-escape-hatch viable + (Postel's Law mitigation), and its correctness is the epic's primary risk: with no opt-out, an + unrecognized legitimate shape is a CI-blocking false positive. Mitigation — `platform-guard` + is `RuleTester`-tested against guard shapes harvested from the existing suite, and an + unrecognized legitimate shape is fixed by teaching the helper, never by adding an opt-out. +- **Drift guard** — a plain unit test (**not** `RuleTester`, which only feeds code *strings* to + a rule and cannot read files or enumerate exports) parses `src/runtime-homes.cts` (and the + relevant `bin/install.js` exports) with `@typescript-eslint/parser`, walks the AST to collect + exported functions that return a filesystem path, and asserts each is present in + `portability-vocab`'s `PATH_RETURNING_FNS` (or an explicit, reason-bearing ignore set). A new + resolver that isn't registered fails CI. +- **Wiring** — rules register in `eslint.config.mjs`'s `local` plugin and are set to `error`. + No new `lint:ci` step; they ride the existing `eslint .` (which `lint:ci` runs via `npm run + lint`). The **production** rules additionally require expanding the `eslint.config.mjs` file + globs to cover `bin/install.js` and the build/install scripts — today the globs are + `src/**/*.cts`, `gsd-core/bin/**/*.cjs`, `scripts/**/*.cjs`, and `tests/**/*.test.cjs`, so the + top-level `bin/install.js` named by `DEFECT.WINDOWS-FS-OPS` is **not yet linted**; the glob + expansion lands in the phase that ships `require-fs-op-fallback`. + +### Strictness — enforcing zero escape hatches (Postel's Law) + +Because there is no opt-out, two things must hold: + +1. **Rules must be precise.** Every rule recognizes legitimate platform-gating via + `platform-guard.cjs` and the canonical normalizer forms, so correctly-written + platform-specific code is never flagged. A false positive is a rule bug, fixed in the rule. +2. **The disable directive is itself banned for these rules.** Note `reportUnusedDisableDirectives` + is **not** sufficient — it only flags directives that suppress *nothing*; a developer could + write `// eslint-disable-next-line local/no-path-literal-in-assert` on a genuinely-violating + line and the directive would count as "used" and pass. The ban is enforced by a dedicated + guard: a small `local/no-portability-disable` meta-rule (matching `Program` comments) that + **errors on any `eslint-disable[-next-line|-line]` directive referencing a + `local/`**. This is precise (only the portability rules are protected; + every other rule keeps its normal inline-disable affordance), self-contained (no new + dependency), and is itself unit-tested. `linterOptions.noInlineConfig: true` was rejected as + the mechanism because it would ban *all* inline disables repo-wide, not just the portability + rules. + +## Applied software laws (engineering directive, Step 2.2) + +- **Kernighan's Law / Greenspun's Tenth** — motivate the whole change: stop parsing a language + with regex; use the real parser. +- **Choose Boring Technology** — ESLint + `typescript-eslint` already present; no new tech. +- **Gall's Law** — the migration is **incremental**: each phase adds one rule, fixes its + violations, and removes only that class's hack. The old mechanisms keep running until their + replacement lands. Full teardown is the *last* phase, not the first. +- **Postel's Law** — zero escape hatches raises the precision bar; `platform-guard.cjs` is the + required mitigation so the strict rules never reject legitimate code. +- **Hyrum's Law** — removing `// windows-portability-ok:` breaks existing uses; every current + occurrence is migrated (code restructured or the underlying violation fixed) in the phase + that retires it. The vocab + rule semantics are documented here as the new contract. + +## Consequences + +**Positive:** one mechanism; in-editor feedback; debuggable, unit-tested rules; no grandfather +list; no bespoke comment parser; a documented, extensible architecture. + +**Cost / risk:** fixing every grandfathered violation across the suite is a large, real diff +(~15+ offender files for G1–G6 alone, plus the path-literal/mode-bit sets). Mitigated by +phasing (one rule at a time, each independently reviewed and shipped) and by the rules being +`error` from the moment they land so no new debt accrues. + +**Migration is phased (Gall's Law):** + +- **Phase 0** ADR (this) — the design record. +- **Phase 1–3** `no-path-literal-in-assert`, `no-posix-mode-bit-assert`, `no-unguarded-nonportable-exec`, + each landing with `portability-vocab.cjs` / `platform-guard.cjs` / the `RuleTester` harness as + they are first needed. +- **Phase 4** the G1–G6 rules + fix all grandfathered offenders + delete the ratchet test. +- **Phase 5–6** production `normalize-path-in-content`, `require-fs-op-fallback`. +- **Phase 7** teardown: delete the `windows-test-parity-guard` ratchet + `allowlist-ratchet` usage + for these classes + sweep any residual `// windows-portability-ok:` comments; finalize the + `CONTEXT.md` `DEFECT.WINDOWS-*` predicate rewrite; the forward architecture guide ("how to add a + portability rule"). (The regex script `scripts/lint-windows-test-portability.cjs` was retired + earlier — in Phase 3 — as its `no-unguarded-nonportable-exec` replacement landed.) + +Each implementation phase runs the full engineering directive (rubber-duck → laws → architecture +→ qa-test-architect → strict TDD via `RuleTester` → codex adversarial → Diátaxis → rebase+PR) +and is its own approved child issue + PR under epic #1702. + +## Phase 7 — as-built / acceptance (2026-06-26) + +All seven phases shipped; the architecture is exercised in production and accepted. Two +as-built deviations from the Phase 0 catalog, both within this ADR's precision discipline: + +- **Phase 6 scope — `require-fs-op-fallback` narrowed to rename.** The catalog row named + `DEFECT.WINDOWS-FS-OPS` for "`src/**/*.cts`, build/install". The defect's own `.fix-forward` + defines the cure as *"catch EPERM/EBUSY/EACCES, fall back to copy + unlink with retry"* — so + `copyFile`/`unlink` are the **fallback primitives**, not separate defect sites, and flagging + them would flag the cure (`unlink` also has ~30 intentional best-effort cleanup sites that would + be a FP minefield). v1 recognition is therefore `fs.rename`/`fs.renameSync` only, with the + `RENAME_RETRY_ERRNOS` retry loop as the recognized compliant shape; `copyFile`/`unlink` + transient-lock sub-classes are documented for a possible follow-up. The ADR-mandated glob + expansion to `bin/install.js` + `scripts/build-hooks.js` (L124-126) landed as specified. + Documented on [#1740](https://github.com/open-gsd/gsd-core/issues/1740). + +- **Phase 6 precision tightening (codex review).** The rule's compliance shape was tightened after + an adversarial gpt-5.5 review: a catch must BOTH reference a transient errno AND carry a retry + signal (a loop `continue` backedge or a `return ` delegation — NOT a bare rethrow), and + only the **nearest catching** try/catch counts (an outer errno-catch is unreachable once an inner + catch intercepts). This enforces the defect's *"never silently swallow"* + cure-is-retry clauses + honestly. See [`eslint-rules/require-fs-op-fallback.cjs`](../../eslint-rules/require-fs-op-fallback.cjs). + +The forward "how to add a portability rule" recipe delivered by this phase lives at +[`docs/contributing/adding-a-portability-rule.md`](../contributing/adding-a-portability-rule.md). + +Two further as-built deviations from the Phase 0 text, reconciled in the post-merge +coverage audit (#1749): + +- **Disable-ban mechanism — meta-rule → out-of-band test.** §"Strictness" specified a + `local/no-portability-disable` ESLint meta-rule to ban inline disables of portability + rules. What shipped is [`tests/portability-rule-disable-ban.test.cjs`](../../tests/portability-rule-disable-ban.test.cjs) + — a `node:test` that scans files for disable directives **outside ESLint**, so it cannot + itself be eslint-disabled (an advantage over an in-process meta-rule, which the ADR noted + as the motivating risk). The substitution is at least as strong; recorded here so the + ADR's written mechanism matches the as-built one. + +- **Drift-guard `bin/install.js` scope.** §"Architecture" said the drift guard parses + `src/runtime-homes.cts` *and the relevant `bin/install.js` exports*. The shipped + [`tests/portability-vocab-drift.test.cjs`](../../tests/portability-vocab-drift.test.cjs) + originally covered only `runtime-homes.cts`; the audit extended it to `bin/install.js` + with a SOUND shape only (a top-level function that directly `return path.*(...)` must be + registered; plus a curated two-way existence lock on the installer path helpers). The + looser body-contains heuristic used for `runtime-homes.cts` is unsound for the generated + 12k-line installer (~33 false positives), so a new installer resolver that builds a path + via a temp variable relies on review — documented as a boundary in the test. The active + resolver module (`runtime-homes.cts`) remains fully drift-guarded by the looser heuristic. + +## Alternatives considered + +1. **Keep extending the regex lint.** Rejected — the adversarial review proved it is + structurally fragile; every extension adds parser surface and bugs. +2. **Keep the ratchet, just add rules.** Rejected — grandfathering is the thing being removed; + the maintainer's directive is rip-and-replace, not legacy preservation. +3. **Keep `// windows-portability-ok:` as an escape hatch.** Rejected — zero escape hatches + chosen; precision via `platform-guard.cjs` replaces the need for an opt-out. +4. **A standalone custom AST tool (not ESLint).** Rejected — Greenspun/Choose-Boring: ESLint is + the boring, in-stack, in-editor linter; building a parallel tool repeats the original mistake. diff --git a/docs/adr/550-spec-phase-probe-contract.md b/docs/adr/550-spec-phase-probe-contract.md index ce7597c2b..8389b1245 100644 --- a/docs/adr/550-spec-phase-probe-contract.md +++ b/docs/adr/550-spec-phase-probe-contract.md @@ -142,3 +142,40 @@ This ratifies the **deterministic SOURCE** for the test-tier `CheckDescriptor` t 5. **Out of scope (unchanged boundaries).** Machine-proven fail-first (a violation-fixture / RuleTester-invalid proof replacing the `failFirst` caller attestation) stays tracked as **#1279**. The `dispositionForProhibition` green/fail-closed **policy** is untouched. No new check kinds are added. Net effect on D3: the prohibition-item shape is extended with three optional, backward-compatible flat-scalar keys that give the test-tier locate a deterministic spec-phase source; the contract's CI-testable surface (D5) gains the projection round-trip parity (CHK-03), the fail-closed guard (CHK-06), and the byte-stable backward-compat fixture (CHK-07). The decision also lives in `src/probe-core.cts` / `src/prohibition-enforcement.cts` comments, the `verify-phase.md` / `spec-phase.md` prose, and the #1278 changeset. + +## Addendum (2026-06-22) — Alternatives considered (recall / representation / packaging side) + +This consolidates the spec-phase-side rejected and deferred alternatives for the probe family, +so a re-proposal meets a recorded reason rather than a fresh debate. (The enforcement-mechanism +alternatives — the flat-vs-nested descriptor, the inline violation snippet, `failFirst` +attestation, and mandatory causation control — are recorded in **ADR-1606**, the +prohibition-enforcement verify-time seam.) + +- **A standalone LLM requirement *classifier* as a feature — REJECTED (#652, closed + 2026-06-04).** The enhancement "requirement classification in the spec phase should use an + LLM-assisted classifier" was closed without approval: the edge-probe's shape taxonomy plus + the prohibition probe's adversarial recall **already capture most of the value** a general + classifier would, without adding a separate model-dependent surface to maintain. *Re-open + only if* a classifier demonstrably beats both probes on a held-out battery. + +- **A deterministic `prohibition-probe.cjs` recall engine — REJECTED.** Unlike the closed edge + taxonomy, the prohibition recall stage is inherently LLM prose reasoning; only the + schema/projection layer is real code (Decision 7b). A deterministic recall adapter is the + scope-creep flagged in `gsd-core/references/prohibition-probe.md`; recall is validated + offline (N18), not asserted in CI. *Re-open only if* recall can be made deterministic without + collapsing the adversarial open-question that gives it model-robust reach. + +- **A `polarity` field on `truths` — REJECTED (already decided — see Decision 3).** `truths` + are positive observables with no `verify.cjs` handler; a prohibition parked there inherits + non-enforcement. Recorded in Decision 3 ("`truths` is left untouched — no `polarity` field is + added"); listed here only so the alternatives set is readable in one place. + +- **A single dispatcher CLI for all probes — DEFERRED (already decided — see Decision 7e).** + Each probe ships its own bin calling a shared `runProbeCli(...)`; a unified dispatcher is + deferred as pure invocation plumbing with no migration debt. Recorded in Decision 7e; listed + here only for completeness. + +*Net:* the two NEW entries (#652 classifier, deterministic recall engine) are the only ones +this addendum adds to 550's decision record; the other two are cross-references to existing +decisions, collected so the probe family's full "alternatives considered" set is readable in +one place. diff --git a/docs/contributing/adding-a-portability-rule.md b/docs/contributing/adding-a-portability-rule.md new file mode 100644 index 000000000..008e52204 --- /dev/null +++ b/docs/contributing/adding-a-portability-rule.md @@ -0,0 +1,150 @@ +# Adding a cross-platform portability lint rule + +GSD must run correctly on Windows as well as macOS/Linux. The `DEFECT.WINDOWS-*` +taxonomy in [`CONTEXT.md`](../../CONTEXT.md) names the recurring failure shapes; a family of +AST-based ESLint rules (the `local/*` plugin) enforces them **at write-time (in your editor) +and in CI**, so a Windows-only defect is caught before it ships — not after it reaches the +`windows-latest` CI lane. + +This page is the **forward recipe**: how to add a new rule when you identify a portability +defect class that isn't yet mechanically enforced. The architecture and rationale live in +[ADR-1703](../adr/1703-portability-enforcement-architecture.md); the per-rule reference and +fix how-tos live in [`cross-platform-portability-rules.md`](./cross-platform-portability-rules.md). + +> **Diátaxis note:** this is an *Explanation* — it describes the architecture and the reasoning +> behind the seams, so the recipe at the end makes sense. For "how do I fix a violation I got", +> see the per-rule how-tos in the reference page. + +## Why AST rules, not regex + +The original enforcement was a regex scanner with a hand-rolled balanced-paren parser, a frozen +`KNOWN_OFFENDERS` ratchet, and a bespoke `// windows-portability-ok:` comment opt-out. Adversarial +review of an attempt to *extend* the regex found it silently could not match `deepStrictEqual`, +had loose normalizer recognition, and hand-rolled paren-splitting fragility (Kernighan's Law / +Greenspun's Tenth — parsing a language with regex). The rip-and-replace decision: **one mechanism, +AST-based ESLint rules** using the parsers already in the stack, hard-fail with zero escape +hatches, no ratchet/grandfathering. Full rationale: ADR-1703 "Alternatives considered". + +## The five seams (and where each lives) + +Every portability rule composes the same five seams. Adding a rule means touching each one. + +### 1. The rule — `eslint-rules/.cjs` + +One file per rule, exporting `{ meta, create }`. Matches real syntax nodes (`CallExpression`, +`MemberExpression`, `Literal`, `TemplateLiteral`, `BinaryExpression`, `TryStatement`, …), **not** +text. Each rule runs in-editor *and* in CI via the existing `eslint .` (invoked by `lint:ci`). + +The two shapes that recur: +- **Test-side rules** (surface `tests/**/*.test.cjs`) — flag a non-portable *assertion* or *test + fixture* shape (path-literal-in-assert, posix-mode-bit-assert, unguarded exec, CRLF split, + hardcoded `/tmp`, bare npm, HOME-without-USERPROFILE). +- **Production rules** (surface `src/**/*.cts`, `bin/install.js`, `scripts/build-hooks.js`) — flag + a non-portable *production* shape (path-leak-in-content, unguarded fs-rename). + +### 2. The shared vocabulary — `eslint-rules/lib/portability-vocab.cjs` + +The single source of truth for path-related portability: `PATH_RETURNING_FNS` (Node builtins + +project resolvers), the POSIX-normalizer recognizers (`.replace(/\\/g,'/')`, `toPosixPath`, …), +and string-unwrap helpers. A new path resolver added to `src/runtime-homes.cts` MUST be registered +here — the drift-guard test (`tests/portability-vocab-drift.test.cjs`) parses that source and +**fails CI if a path-returning export is missing** from `PATH_RETURNING_FNS`. + +### 3. The platform guard — `eslint-rules/lib/platform-guard.cjs` + +The precision backbone. `isWindowsExcludedNode(node, sourceCode)` answers "is this node +control-dependent on a Windows platform condition?" via a **dominator check, not a textual mention**: +`if (process.platform !== 'win32') { … }`, early-return guards (`if (process.platform === 'win32') return;`), +`os.platform()`, and hoisted binding-aware booleans (`const isWindows = …` consumed by +`if (!isWindows)`, with reassignment detection). This is what makes **zero escape hatches** viable: +legitimately POSIX-only code is *structured* behind a recognized guard, never annotated around +(Postel's Law mitigation). If a legitimate shape isn't recognized, **teach the helper** — never add +an opt-out. + +### 4. The disable ban — `tests/portability-rule-disable-ban.test.cjs` + +Because there is no opt-out, an `eslint-disable` of a portability rule would silently bypass it. +This test runs **outside ESLint** (so it cannot itself be eslint-disabled) and fails the build on +any `eslint-disable[-next-line|-line]` that names a protected portability rule, or any blanket +disable. **Every new rule MUST be appended to `PROTECTED_RULES`** here, and if the rule covers a +new surface (e.g. `bin/install.js`), that surface MUST be added to `collectTestFiles()`. + +### 5. CI test selection — `scripts/ci-test-scope.cjs` + +The `portability lint rules (ADR-1703)` rule selects the rule suites + disable-ban when +`eslint-rules/`, `eslint.config.mjs`, or a covered production surface changes. Add new test files +to its `tests:` list. + +## The zero-escape-hatch contract + +Two things must hold, and they are the epic's primary risk: + +1. **Rules must be precise.** Every rule recognizes legitimate platform-gating via + `platform-guard.cjs` and the canonical compliant shapes, so correctly-written platform-specific + code is never flagged. A false positive is a rule bug, fixed in the rule — never by adding an + opt-out. +2. **Recognition must mean the cure, not just the symptom.** When a rule's compliance shape is "the + catch handles the transient errno", the handler must actually *retry/fallback* (a loop `continue` + backedge or a `return ` delegation), not merely *reference* the errno and rethrow. The + `require-fs-op-fallback` rule encodes this (codex-review-tightened): the defect's cure is retry, + not just recognition. + +An unrecognized legitimate shape is fixed by teaching the helper/rule, never by annotation. This is +the discipline that keeps the rules honest as the codebase grows. + +## Recipe — add a new `local/*` portability rule + +Run the full engineering directive (rubber-duck → software laws → architecture → qa-test-architect +→ strict TDD via `RuleTester` → adversarial review → Diátaxis → rebase+PR). Concretely: + +1. **Classify the defect.** Confirm it's a real `DEFECT.WINDOWS-*` shape (or a new class worth a + predicate in `CONTEXT.md`). Decide the *sound* statically-detectable scope — narrow or document + rather than ship FP-prone (Phase 5/6 each narrowed scope and documented the boundary). +2. **Write the rule** — `eslint-rules/.cjs` (`{ meta, create }`, `type: 'problem'`, + message cites the `DEFECT.*` predicate). Reuse `portability-vocab.cjs` / `platform-guard.cjs`. +3. **TDD via `RuleTester`** — `tests/.rule.test.cjs`. Cover: the violation shape(s), + every recognized compliant shape (platform guard, normalizer, retry signal, …), and the + anti-patterns that must NOT satisfy compliance (silent-swallow catch, rethrow-only, unrelated + errno). Use both espree (`.cjs`) and `@typescript-eslint/parser` (`.cts`) where the rule spans + both. Tests are written FIRST and must fail before the rule exists, then pass. +4. **Register + scope** — in `eslint.config.mjs`: add the rule to the `local` plugin's `rules` map + and enable at `'error'` in the matching file-glob block. If the rule covers a new surface (e.g. + `bin/install.js`), add a config block for it — apply ONLY the portability rules to generated + code, not the full recommended set. +5. **Disable ban** — append the rule name to `PROTECTED_RULES` in + `tests/portability-rule-disable-ban.test.cjs`; add any new surface to `collectTestFiles()`. +6. **CI selection** — add the new test file to the `portability lint rules (ADR-1703)` rule in + `scripts/ci-test-scope.cjs`. +7. **Fix every violation** — no ratchet, no grandfathering. Every existing + grandfathered offender + is fixed in the same phase (route through a shared helper, add a guard, or normalize). +8. **Docs** — add the rule to the reference table + a fix how-to in + `cross-platform-portability-rules.md`; rewrite the `DEFECT.*` predicate's `detect=`/`fix-forward=` + in `CONTEXT.md` to point at the rule; record known boundaries honestly. +9. **Verify** — `npm run lint:ci` green; the touched modules' tests green; the `windows-latest` CI + lane is the only true Windows signal. + +## Catalog (shipped) + +| Rule | DEFECT | Surface | Phase | +|---|---|---|---| +| `no-path-literal-in-assert` | `WINDOWS-PATH-LITERAL-IN-ASSERT` | tests | 1 | +| `no-posix-mode-bit-assert` | `WINDOWS-POSIX-MODE-BIT-ASSERT` | tests | 2 | +| `no-unguarded-nonportable-exec` | `WINDOWS-TEST-PORTABILITY` (chmod+`sh -c`) | tests | 3 | +| `no-crlf-fragile-split` | `WINDOWS-TEST-PORTABILITY` (G1–G3) | tests | 4 | +| `no-hardcoded-tmp` | `WINDOWS-TEST-PORTABILITY` (G4) | tests | 4 | +| `no-bare-npm-exec` | `WINDOWS-TEST-PORTABILITY` (G5) | tests | 4 | +| `require-userprofile-with-home` | `WINDOWS-TEST-PORTABILITY` (G6) | tests | 4 | +| `normalize-path-in-content` | `WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT` | `src/**/*.cts` | 5 | +| `require-fs-op-fallback` | `WINDOWS-FS-OPS` | `src/**/*.cts`, `bin/install.js`, `scripts/build-hooks.js` | 6 | + +`DEFECT.WINDOWS-ARGV-OVERFLOW` is deliberately **not** in this catalog: argv length is a runtime +property (the args-array size is not statically knowable), so no AST rule can soundly detect it. +It is addressed at the source (`run-tests.cjs` chunking under `RUN_TESTS_MAX_CMDLINE_CHARS`). + +## Teardown (complete) + +The legacy machinery this architecture replaced is fully retired: the regex scanner +`scripts/lint-windows-test-portability.cjs` (Phase 3), the `tests/windows-test-parity-guard.test.cjs` +ratchet (Phase 4), `allowlist-ratchet.cjs` usage for portability classes (the module remains for +unrelated size-budget lints), and the `// windows-portability-ok:` comment convention (swept — zero +remain). Every `DEFECT.WINDOWS-*` predicate in `CONTEXT.md` now points at its enforcing rule. diff --git a/docs/contributing/cross-platform-portability-rules.md b/docs/contributing/cross-platform-portability-rules.md new file mode 100644 index 000000000..2d53bb281 --- /dev/null +++ b/docs/contributing/cross-platform-portability-rules.md @@ -0,0 +1,309 @@ +# Cross-platform portability lint rules + +GSD must run on Windows as well as macOS/Linux. A family of AST-based ESLint rules (the +`local/*` plugin) enforces the `DEFECT.WINDOWS-*` portability classes documented in +[`CONTEXT.md`](../../CONTEXT.md) **at write-time (in your editor) and in CI**, so a +Windows-only defect is caught before it ships — not after it reaches the `windows-latest` CI +lane. The architecture and rationale are in [ADR-1703](../adr/1703-portability-enforcement-architecture.md); +this page is the practical reference + how-to. + +> **Adding a new rule?** See [`adding-a-portability-rule.md`](./adding-a-portability-rule.md) — +> the five seams (rule / vocab / platform-guard / disable-ban / ci-scope), the zero-escape-hatch +> contract, and the step-by-step recipe. + +These rules are **hard-fail with zero escape hatches**: there is no `// windows-portability-ok:` +comment and no `eslint-disable` for them (a `tests/portability-rule-disable-ban.test.cjs` check, +running outside ESLint, fails the build if you try). Legitimately platform-specific code must be +*structured* so the rule recognizes it (see "Platform guards" below) — not annotated around. + +## Reference — the rules + +| Rule | Flags | Surface | +|---|---|---| +| `local/no-path-literal-in-assert` | An `assert.equal`/`strictEqual`/`deepEqual`/`deepStrictEqual` or `expect(...).toBe`/`toEqual`/`toStrictEqual` where one operand is a **path-returning function call** and the other is a **hardcoded `/`-string literal** not normalized to POSIX. | `tests/**/*.test.cjs` | +| `local/no-posix-mode-bit-assert` | An equality assertion comparing a file **`.mode`** (e.g. `statSync(p).mode & 0o777`) to an **octal literal** — Windows reports `0o666`/`0o444`, never the requested mode. | `tests/**/*.test.cjs` | +| `local/no-unguarded-nonportable-exec` | A file that **both** sets a chmod exec-bit (`chmod`/`chmodSync` with `0oNNN & 0o111 !== 0`) **and** invokes `sh`/`bash` with a `-c` flag (`execFileSync`/`spawnSync`/`spawn`/`exec`/`execSync`) without a Windows platform guard — Windows Git Bash ignores the exec bit for extension-less PATH-executed scripts. | `tests/**/*.test.cjs` | +| `local/no-crlf-fragile-split` | A `.split('\n')` or `.split("\n")` call on `readFileSync` content, **or** a regex literal containing a bare `\n` used against `readFileSync` content — Windows `git-autocrlf` yields `\r\n` line endings so a literal `\n` split or regex will mismatch. | `tests/**/*.test.cjs` | +| `local/no-hardcoded-tmp` | A hardcoded `/tmp/` string passed as the first argument to an `fs.*` function or `path.join` — `/tmp` does not exist on Windows. Use `os.tmpdir()` instead. | `tests/**/*.test.cjs` | +| `local/no-bare-npm-exec` | An `execFileSync`/`spawnSync`/`spawn` call with `"npm"` as the command and no `{ shell: true }` option (or a platform-guarded equivalent) — `npm` is a `.cmd` batch wrapper on Windows and is not found without a shell. (`execSync`/`exec` already run via a shell, so they are not flagged.) | `tests/**/*.test.cjs` | +| `local/require-userprofile-with-home` | A `process.env.HOME = ` assignment in a test file with no corresponding `process.env.USERPROFILE` **assignment** — Windows uses `USERPROFILE` as the home directory environment variable, not `HOME`. | `tests/**/*.test.cjs` | +| `local/normalize-path-in-content` | A path-returning fn result (excluding `path.basename`, which returns a separator-less filename) interpolated **directly** into content without `.replace(/\\/g,'/')` normalization — backslash paths leak into generated content on Windows (`RULESET.CONTENT-PATH-NORMALIZATION`). Two content shapes are detected: (a) the template/string contains an `@`-reference marker (`@~/`, `@$`, `@/`), `$HOME`, or `~/`; (b) the quasi immediately following the interpolation starts with `/…\.md` or `/…\.json`. **Indirect data-flow** (path stored in a variable/field then interpolated) is not detected — normalize at source. Fix: `String(resolvedTarget).replace(/\\/g, '/')`. | `src/**/*.cts` | +| `local/require-fs-op-fallback` | An unguarded `fs.rename` / `fs.renameSync` (the atomic-publish primitive) that is NOT inside a `try`/`catch` whose handler references a transient errno (`'EPERM'`/`'EBUSY'`/`'EACCES'`, or a `*RETRY_ERRNOS` set) AND is NOT behind a Windows platform guard — on Windows a concurrent reader / antivirus scanner can transiently hold the target open and throw. A `catch (e) {}` that silently swallows, or a catch that cleans-up-and-rethrows without an errno check, does **not** satisfy the rule. `fs.copyFile` / `fs.unlink` are deliberately **not** flagged (they are the *fallback primitives* named by the defect's own fix-forward, and `unlink` has many intentional best-effort cleanup sites). | `src/**/*.cts`, `bin/install.js`, `scripts/build-hooks.js` | + +(See ADR-1703's catalog and [epic #1702](https://github.com/open-gsd/gsd-core/issues/1702) for the full phase history.) + +The set of path-returning functions is single-sourced in +[`eslint-rules/lib/portability-vocab.cjs`](../../eslint-rules/lib/portability-vocab.cjs) as +`PATH_RETURNING_FNS` (Node's `path.*`/`os.homedir`/`os.tmpdir` plus the project resolvers such as +`getGlobalConfigDir`, `resolveAgentDir`, `computePathPrefix`, …). A drift-guard test +(`tests/portability-vocab-drift.test.cjs`) parses `src/runtime-homes.cts` and **fails CI if a new +path resolver is added but not registered** in that list. + +## How-to — fix a `no-path-literal-in-assert` violation + +Why it fails on Windows: `path.join('a','b')` returns `a/b` on POSIX but `a\b` on Windows, so +`assert.equal(path.join('a','b'), '/a/b')` passes on your Mac/Linux machine and the docker gate, +then fails only on the `windows-latest` lane. + +**Fix: normalize the ACTUAL operand to POSIX before comparing** — this is idempotent on POSIX +(a no-op when there are no backslashes) and *reveals* a malformed return rather than masking it: + +```js +// ❌ flagged +assert.strictEqual(getGlobalConfigDir('claude'), '/custom/claude'); + +// ✅ compliant +assert.strictEqual(String(getGlobalConfigDir('claude')).replace(/\\/g, '/'), '/custom/claude'); +``` + +Do **not** instead wrap the *expected* literal in `path.join(...)` to match the platform +separator — that passes everywhere but masks a wrong backslash-on-POSIX return (both sides wrong +together). Recognized normalizers: `.replace(/\\/g,'/')`, `.replace(/[\\/]/g,'/')`, +`.replaceAll('\\','/')`, `.replaceAll(path.sep,'/')`, `.split(path.sep).join('/')`, +`toPosixPath(...)`. + +## How-to — fix a `no-posix-mode-bit-assert` violation + +Windows does not honor POSIX file modes — `fs.statSync(p).mode` reads back `0o666` (writable) or +`0o444` (readonly), never the `0o644`/`0o755` you wrote. A mode-bit assertion is therefore a +POSIX-only precondition. **Gate it behind a platform check and keep the real behavioral assertion +running on every OS** (do not delete it — scope it): + +```js +// ❌ flagged +assert.strictEqual(fs.statSync(p).mode & 0o777, 0o644); + +// ✅ scope the POSIX-only precondition; keep the behavioral assertion cross-platform +if (process.platform !== 'win32') { + assert.strictEqual(fs.statSync(p).mode & 0o777, 0o644); +} +assert.match(hookCommand, /^node /); // behavioral assertion — runs everywhere +``` + +Prefer asserting the *behavior* (command shape, runnability) over the raw mode bit where you can. + +## How-to — fix a `no-unguarded-nonportable-exec` violation + +Why it fails on Windows: Windows Git Bash (msys2) does not honour Node's chmod exec bit for +extension-less scripts that are invoked by searching PATH. A test that makes a fixture executable +with `chmodSync(p, 0o755)` and then runs it with `execFileSync('bash', ['-c', '...'])` passes on +macOS/Linux but fails only on the `windows-latest` CI lane (DEFECT.WINDOWS-TEST-PORTABILITY). + +**Fix option A: gate the `sh`/`bash -c` invocation behind a platform check** + +```js +// ❌ flagged +fs.chmodSync(fixture, 0o755); +execFileSync('bash', ['-c', './fixture run']); + +// ✅ platform-guarded +fs.chmodSync(fixture, 0o755); +if (process.platform !== 'win32') { + execFileSync('bash', ['-c', './fixture run']); +} +``` + +**Fix option B: invoke the script with an explicit interpreter (no -c flag)** + +```js +// ✅ passes the script path directly — exec bit not needed +execFileSync('sh', [fixturePath]); +``` + +## Platform guards (the only "escape" — by structure, not annotation) + +If an assertion is *genuinely* POSIX-only, gate it behind a Windows platform check the rule +recognizes — it then won't flag the guarded code. Recognized shapes: + +```js +if (process.platform !== 'win32') { + assert.equal(path.join(a, b), '/a/b'); // guarded → not flagged +} + +if (process.platform === 'win32') return; // early-return guard +assert.equal(path.join(a, b), '/a/b'); // → not flagged + +const isWindows = process.platform === 'win32'; // hoisted boolean (any name, binding-resolved) +if (!isWindows) assert.equal(path.join(a, b), '/a/b'); // → not flagged +``` + +The guard is recognized by control-dependence (it must actually dominate the assertion), is +binding-aware (a reassigned or `false`-initialized variable is not trusted), and handles +`os.platform()` and `node:test` skip returns. See +[`eslint-rules/lib/platform-guard.cjs`](../../eslint-rules/lib/platform-guard.cjs). + +> **Note:** the `node:test` `test(name, { skip: isWindows ? … : false }, fn)` *option* object is +> NOT recognized as a platform guard. To scope a POSIX-only assertion use an +> `if (process.platform !== 'win32')` guard (or early-return) **inside** the callback. + +## How-to — fix a `no-crlf-fragile-split` violation + +Windows `git-autocrlf=true` (the default on Windows) rewrites `\n` to `\r\n` in checked-out files. +A test that reads a file with `readFileSync` and then splits on `'\n'` (or uses a regex with a bare +`\n`) will silently miscalculate line counts on Windows. + +**Fix: use `/\r?\n/` everywhere you split or match lines in file content:** + +```js +// ❌ flagged +const lines = fs.readFileSync(p, 'utf8').split('\n'); +assert.match(content, /^---\n/m); +assert.match(content, /```bash\n/); + +// ✅ CRLF-safe +const lines = fs.readFileSync(p, 'utf8').split(/\r?\n/); +assert.match(content, /^---\r?\n/m); +assert.match(content, /```bash\r?\n/); +``` + +The `/\r?\n/` form is a no-op on POSIX (matches only `\n`) and correct on Windows (matches `\r\n`). + +## How-to — fix a `no-hardcoded-tmp` violation + +`/tmp` does not exist on Windows. Use `os.tmpdir()` to get the platform-appropriate temp directory: + +```js +// ❌ flagged +const dir = path.join('/tmp/my-test-dir', 'sub'); +env.MY_VAR = '/tmp/custom-dir'; + +// ✅ portable +const dir = path.join(os.tmpdir(), 'my-test-dir', 'sub'); +const customDir = path.join(os.tmpdir(), 'custom-dir'); +env.MY_VAR = customDir; +``` + +When the same `/tmp/...` value is used both as a fixture env var and in an assertion, update both +sides consistently so they still match: + +```js +// ❌ fragile — assertion tied to /tmp/ literal +const customDir = path.join(os.tmpdir(), 'custom-dir'); +env.MY_VAR = customDir; +assert.strictEqual(String(fn()).replace(/\\/g, '/'), '/tmp/custom-dir'); // ← still wrong + +// ✅ assertion uses the same derived constant +assert.strictEqual(String(fn()).replace(/\\/g, '/'), customDir.replace(/\\/g, '/')); +``` + +## How-to — fix a `no-bare-npm-exec` violation + +On Windows, `npm` is installed as `npm.cmd` (a CMD batch script). Without `{ shell: true }`, +`execFileSync('npm', ...)` fails because the OS cannot find an executable named `npm` (no `.cmd` +extension). Add `shell: true` or gate the call behind a platform check: + +```js +// ❌ flagged +execFileSync('npm', ['ci'], { cwd: dir }); + +// ✅ shell: true — works on all platforms +execFileSync('npm', ['ci'], { cwd: dir, shell: true }); + +// ✅ platform-guarded alternative +execFileSync('npm', ['ci'], { cwd: dir, shell: process.platform === 'win32' }); +``` + +## How-to — fix a `require-userprofile-with-home` violation + +Windows uses `USERPROFILE` as the home directory environment variable, not `HOME`. Whenever a test +sets `process.env.HOME`, it must also set `process.env.USERPROFILE` to the same value (so that +code under test that calls `os.homedir()` or reads `process.env.USERPROFILE` gets the isolated +directory on Windows too). Mirror the teardown as well: + +```js +// ❌ flagged — Windows code-under-test reads USERPROFILE, not HOME +const origHome = process.env.HOME; +process.env.HOME = isolatedDir; +// … +process.env.HOME = origHome; // restore + +// ✅ set and restore both +const origHome = process.env.HOME; +const origUserProfile = process.env.USERPROFILE; +process.env.HOME = isolatedDir; +process.env.USERPROFILE = isolatedDir; +// … +if (origHome === undefined) delete process.env.HOME; else process.env.HOME = origHome; +if (origUserProfile === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = origUserProfile; +``` + +## How-to — fix a `require-fs-op-fallback` violation + +Why it fails on Windows: `fs.renameSync(tmp, target)` (the atomic-publish primitive) uses Windows +`MoveFileEx` with `MOVEFILE_REPLACE_EXISTING`, which throws `EPERM`/`EBUSY`/`EACCES` when an +antivirus scanner, indexer, or concurrent reader transiently holds the target open. On macOS/Linux +`rename(2)` atomically replaces regardless of open handles, so the bare call passes everywhere +except the `windows-latest` CI lane (DEFECT.WINDOWS-FS-OPS). + +**Fix option A (preferred for production): route through `retryRenameSync`** — the shared drop-in +from `shell-command-projection.cjs` that retries the transient errnos a bounded number of times +before rethrowing. It is idempotent on POSIX (the transient errnos do not occur there): + +```js +import { retryRenameSync } from './shell-command-projection.cjs'; + +// ❌ flagged — EPERM/EBUSY propagates unhandled on Windows +fs.renameSync(tmpPath, target); + +// ✅ drop-in — retries transient locks, throws on persistent failure +retryRenameSync(tmpPath, target); +``` + +**Fix option B: inline the `RENAME_RETRY_ERRNOS` loop** (the convention already used by +`capability-ledger`, `capability-consent`, and `shell-command-projection`'s own `atomicRenameWithRetry`): + +```js +const RENAME_RETRY_ERRNOS = new Set(['EPERM', 'EBUSY', 'EACCES']); +for (let attempt = 1; attempt <= 3; attempt++) { + try { + fs.renameSync(tmpPath, target); + break; + } catch (err) { + if (attempt < 3 && RENAME_RETRY_ERRNOS.has(err.code)) { backoff(); continue; } + throw err; + } +} +``` + +**Fix option C: gate behind a platform check** when the rename is genuinely POSIX-only: + +```js +// ✅ platform-guarded — not flagged +if (process.platform !== 'win32') { + fs.renameSync(tmpPath, target); +} +``` + +> **`copyFile` / `unlink` are not flagged.** Per the defect's own fix-forward, they are the +> *fallback primitives* ("catch EPERM/EBUSY/EACCES, fall back to copy + unlink with retry"), not +> separate defect sites. A retry delegated to a helper that itself wraps `renameSync` in the +> `RENAME_RETRY_ERRNOS` loop is compliant because the helper's own `renameSync` is recognized; a +> bare `fs.renameSync(...)` call is what gets flagged. + +## How-to — add a new path resolver + +When you add a function that returns a filesystem path (e.g. in `src/runtime-homes.cts`), add its +name to `PATH_RETURNING_FNS` in `eslint-rules/lib/portability-vocab.cjs`. The drift-guard test +will fail until you do. + +## Known boundaries + +The rule matches by spelling and inspects the direct operand (or a `String()` wrapper): + +- It assumes `path`/`os` are the standard modules and the resolver names are the project's — a + local variable that *shadows* one of those names in a test file is out of scope. +- Deeper wrapping (e.g. `realpathSync(path.join(...))`, `.toLowerCase()` on a path) is not + inspected; assert against the path call directly or its `String(...)` wrap. +- For a genuine explicit-dir *pass-through* assertion (a resolver that returns its input + verbatim), the `String(...).replace(/\\/g,'/')` remedy is a harmless no-op. +- **The rule catches a path-returning call interpolated *directly* into `${ }`.** It does NOT + track **indirect data-flow** — a path stored in a variable or object field, then interpolated + (e.g. `${globalSkillDir}/SKILL.md` → `@${entry.ref}`). Indirect content-path-leaks rely on + `RULESET.CONTENT-PATH-NORMALIZATION` discipline (normalize at source) and code review. + The one known indirect leak (`src/init.cts` `cmdAgentSkills` `entry.ref` building) is fixed + by normalizing at the content-emit site: `- @${String(entry.ref).replace(/\\/g, '/')}`. +- **Content detection shape (b)** fires when the quasi *immediately following* the interpolation + starts with `/…\.md` or `/…\.json`. A bare `.md` or `.json` token in the *middle* of prose + (e.g. `: see README.md`) does NOT qualify — the quasi must start with the forward slash. + Config-dir substrings (`/.claude`, `/commands`, `/skills`, etc.) are deliberately NOT content + markers — they caused false positives on log/error/diagnostic strings mentioning config dirs. diff --git a/docs/how-to/add-or-update-a-host-integration.md b/docs/how-to/add-or-update-a-host-integration.md new file mode 100644 index 000000000..5dd8a1fc5 --- /dev/null +++ b/docs/how-to/add-or-update-a-host-integration.md @@ -0,0 +1,111 @@ +# How to add or update a host's integration capabilities + +This guide is for GSD maintainers adding a new host CLI, or updating an existing host's +host-integration axes (ADR-1239 Phase A). It covers the **documentation-sourcing rule**, the +eight `runtime.hostIntegration` axes, the `undocumented` sentinel, and how to validate. + +The governing rule for this whole process: **every axis value must come from the host's own +authoritative documentation. Never infer, guess, or assume.** Where the docs do not state an axis, +record the explicit `undocumented` sentinel — not a plausible default. The reference matrix +(`docs/reference/host-integration-capability-matrix.md`) is the source of truth, and every value in +it carries a citation and an evidence quote. + +--- + +## 1. Find the host's authoritative documentation + +In order of preference: + +1. **Context7** — `resolve-library-id` for the host, then `query-docs` for "plugins / subagents / hooks / commands / MCP / model API". +2. **Official dev docs / source repo** — the host's documentation site or GitHub repo (plugin API, agents, hooks, MCP, command authoring). + +Capture the exact source (Context7 library id + query, or the doc URL) and a short verbatim quote +for each value you determine. You will paste these into the matrix in step 4. + +## 2. Determine each of the eight axes from the docs + +Read the docs and map them to the closed vocabulary. Do not pick a value unless a source states it. + +| Axis | What to look for in the docs | +|---|---| +| `embeddingMode` | An in-process programmatic plugin/extension API (`imperative`) vs. configuration files only (`declarative`). | +| `commandSurface` | How custom commands are authored/invoked: `slash-file` (.md), `slash-toml`, `slash-programmatic`, `palette`, `prose-only`. | +| `dispatch` | Sub-agent delegation: `namedDispatch`, `nested`, `maxDepth` (int; `-1` = documented-unbounded), `background`, `subagentToolkit` (`full`/`read-only`). | +| `modelMode` | A programmatic model request/provider API (`active`) vs. instruction/per-agent-field only (`passive`). | +| `hookBus` | The host fires lifecycle events a plugin subscribes to (`host`), an extension host owns the bus (`engine`), or no bus (`none`). **Independent of `hooksSurface`** — e.g. opencode has `hooksSurface: none` but `hookBus: host`. | +| `stateIO` | `filesystem`, `sandboxed-storage` (web IDE, no arbitrary FS), or `session-log-append`. | +| `transport` | `mcp` (native MCP support) vs. `native-extension` (MCP needs a community extension). | +| `runtime` | The plugin/extension runtime: `node`, `bun`, `sandboxed-web`, `python`, `go`, `rust`, `electron`, `other`. | + +## 3. Write the `runtime.hostIntegration` block + +In `capabilities//capability.json`, inside the `runtime` object, add (or edit) the block. Use a +documented closed-vocabulary value, or the literal string `"undocumented"` for any axis the docs do +not state: + +```json +"hostIntegration": { + "embeddingMode": "declarative", + "commandSurface": "slash-file", + "dispatch": { "namedDispatch": true, "nested": false, "maxDepth": 1, "background": false, "subagentToolkit": "undocumented" }, + "modelMode": "passive", + "hookBus": "host", + "stateIO": "filesystem", + "transport": "mcp", + "runtime": "node" +} +``` + +**When to use `undocumented`:** only when you searched and the host's docs genuinely do not state the +axis. It validates, but `negotiateHostCapabilities` **fail-closes** on it (degrades to the most +restrictive known value) — so it is always safe and never a silent capability claim. A dispatch +boolean or `maxDepth` may also be `"undocumented"`. + +**Do not conflate the orthogonal axes:** `commandStyle` (GSD's emission style) is *not* +`commandSurface` (the host's surface type); the `hookEvents` dialect is *not* `hookBus` (bus +ownership); `runtimeCompat` (which features run on a host) is independent of these runtime→engine +axes. + +## 4. Record the citations in the reference matrix + +Add (or update) the host's section in `docs/reference/host-integration-capability-matrix.md` with a +row per axis: `Axis | Value | Source | Evidence`. For an `undocumented` value, put the search trail +in the Source column. This file is the deployment source of truth — a value without a citation here +is not allowed. + +## 5. Validate + +```bash +npm run build:lib +npm run gen:capability-registry # validateRuntimeBody runs on every descriptor +``` + +`gen:capability-registry` must succeed with zero errors. The validator +(`gsd-core/bin/lib/capability-validator.cjs`) rejects out-of-vocabulary values, malformed dispatch +structs, and reserved keys (`__proto__`/`constructor`/`prototype`). + +Then run the host-integration tests and the full cross-platform suite: + +```bash +node --test tests/host-integration-descriptors.test.cjs # asserts every descriptor validates + profiles +gsd-test-both # Mac + Linux Docker (run before any PR) +``` + +## 6. If you need a vocabulary value that does not exist yet + +The vocabulary is intentionally **closed** (ADR-857 Decision 8): a genuinely new host shape requires +a first-party primitive, reviewed. To add one (e.g. a new `runtime` kind): + +1. Add the value to the relevant axis in `HOST_INTEGRATION_AXES` in `src/host-integration.cts`. +2. Add the same value to the matching `VALID_*` set in `capability-validator.cjs`. + +The parity guard (`tests/host-integration-validator-parity.test.cjs`) fails if these two drift, so +they must be updated together. Document the new value's meaning in the matrix legend. + +--- + +## Related + +- Reference: [`docs/reference/host-integration-capability-matrix.md`](../reference/host-integration-capability-matrix.md) — the per-CLI sourced values. +- ADR: [`docs/adr/1239-gsd-embeddable-orchestration-engine.md`](../adr/1239-gsd-embeddable-orchestration-engine.md) — why the interface exists and the Phase A amendment. +- The closed-vocabulary runtime descriptor it extends: [ADR-1016](../adr/1016-runtime-capability-descriptor.md). diff --git a/docs/how-to/update-gsd.md b/docs/how-to/update-gsd.md index aeaf5884c..011d0c6e1 100644 --- a/docs/how-to/update-gsd.md +++ b/docs/how-to/update-gsd.md @@ -138,3 +138,13 @@ Each GSD release may include installer migrations that rename, move, or retire m - [Manual update](../manual-update.md) - [Installer migrations](../installer-migrations.md) - [Docs index](../README.md) + +## CLI version-skew warning + +GSD warns (to stderr, non-blocking) when the resolved `gsd-tools.cjs` is **outside your project root** while a project-local install exists — a sign that a global install (often a retired `@gsd-build/sdk` canary) is shadowing your project-local GSD. The warning names the resolved path and, for the `@gsd-build/sdk` case, gives the removal command: + +```bash +npm uninstall -g @gsd-build/sdk +``` + +If you see this warning, remove the stale global package so `gsd_run` resolves the project-local install. diff --git a/docs/proposals/mempalace-capability-prd-adr.md b/docs/proposals/mempalace-capability-prd-adr.md index c3949a74d..1ff30b7dd 100644 --- a/docs/proposals/mempalace-capability-prd-adr.md +++ b/docs/proposals/mempalace-capability-prd-adr.md @@ -239,12 +239,29 @@ All steps/contributions are `onError: skip`. No gates. | **0 — Spike** | `mempalace init`/`mine`/`search`/`wake-up` against gsd-core's own `.planning/`; confirm wing/room mapping feels right | manual: recall surfaces real prior decisions | | **1 — Manifest + registry** | `capabilities/mempalace/capability.json` + `gen-capability-registry.cjs --write`; CI staleness gate green; consistency gate (id≠CLUSTERS collision) | `--check` passes | | **2 — Skills + agent + fragments** | the two skills, the curator agent, two fragment files; `augment` mode only | recall/capture work when invoked manually | -| **3 — Config + federated flow** | all `mempalace.*` keys resolve via federated config; `capability-state` resolver reports the capability | state resolver shows installed/surfaced + hook activity | +| **3 — Config + federated flow** | all `mempalace.*` keys resolve via federated config; `capability-state` resolver reports the capability | state resolver shows installed/surfaced + hook activity; **user can run `gsd capability enable mempalace` and `config-set mempalace.enabled true`** (inherited ADR-857 capability surface — UX-enable) | | **4 — Modes** | `kg_backend` then `replace`; `gsd-graphify` routing seam | each mode round-trips a decision | | **5 — Passive hooks + autonomous** | `auto_capture_hooks` installs native hooks; CLI-path capture verified headless (`/gsd-autonomous`, cron) | headless run captures with no MCP | -| **6 — Loop wiring (blocked on ADR-857 phase-6)** | `loop render-hooks` called from `plan-phase.md`/`execute-phase.md`/etc. so hooks auto-fire | end-to-end auto recall/capture | +| **6 — Loop wiring (shipped via ADR-857)** | the host-loop workflows call `loop render-hooks` at each canonical point, so registered capability hooks auto-fire | with `mempalace.enabled`, a `/gsd-execute-phase` run **auto-produces `MEMORY-RECALL.md` at `plan:pre`**, files capture at `plan:post`/`verify:post` with **no manual invocation**, and the curator spawns at `ship:post` — **verified** (`gsd-tools loop render-hooks plan:pre` returns the `mempalace-recall` step) | -Phases 1–5 ship value **before** ADR-857's phase-6 cutover (the skills are invocable directly). Phase 6 flips them to automatic. +ADR-857 (the capability system + `loop render-hooks` infrastructure) is **released**, so the Phase-6 loop wiring is shipped: the host-loop workflows call `loop render-hooks` at each canonical point, and MemPalace auto-fires through it when `mempalace.enabled`. The skills (`/gsd:mempalace-recall`, `/gsd:mempalace-capture`) are also invocable directly for manual use. + +### 15.1 Decision → Phase ownership (traceability) + +Every design decision (§10) and user-facing capability is the explicit responsibility of exactly one phase. Cross-cutting policies are assigned a **primary** owner (the phase that first embodies them) with later phases that extend them noted: + +| Decision / capability | Primary owner | Notes | +|---|---|---| +| D1 role=`feature` · D10 manifest · **D6 `onError:skip` no-gate policy** | **Phase 1** | D6 is encoded in the manifest's per-step `onError:skip`; every later phase inherits it. | +| D3 transport (MCP-primary / CLI-fallback) · D4 verbatim drawers · D8 wing/room taxonomy · UX-recall · UX-capture | **Phase 2** | D3's MCP-primary rendering lives in the skills/fragments; the CLI-fallback *headless* path is exercised in Phase 5 (UX-headless). | +| D2 tier=`full` opt-in · D11 federated config · UX-enable | **Phase 3** | UX-enable is the **inherited** `gsd capability enable mempalace` + `config-set` surface (ADR-857's CLI), verified in this phase — not a MemPalace-specific command. | +| D5 three modes | **Phase 4** | Deferred from Phase 2 ("augment only"); Phase 4 owns `kg_backend`/`replace` + the `gsd-graphify` routing seam. | +| D7 passive auto-capture · UX-passive · UX-headless | **Phase 5** | Native-hook install + the headless CLI-path transport (D3 fallback). | +| D9 loop-point map (7 points) · UX-auto · UX-curator | **Phase 6** | Wired via the **shipped** ADR-857 `loop render-hooks` infrastructure (ADR-857 is released); auto-fires when `mempalace.enabled` — verified end-to-end. | + +### 15.2 Loop wiring status + +ADR-857 (the capability system + the `loop render-hooks` resolver + the workflow call sites) is **released**. The host-loop workflows (`plan-phase.md`, `execute-phase.md`, `verify-work.md`, `ship.md`, `discuss-phase.md`) call `loop render-hooks ` at each canonical point, so any registered capability — including `mempalace` — auto-fires when its `when` gate is true. **Verified:** `gsd-tools loop render-hooks plan:pre --raw` with `mempalace.enabled: true` returns the `mempalace-recall` step (`capId: mempalace`, `produces: MEMORY-RECALL.md`), rendered into the workflow markdown. There is therefore **no outstanding cross-doc gating dependency** for UX-auto / UX-curator — the earlier "blocked on ADR-857 *Migrate*" framing (in the original §15 and a prior audit comment) is retracted: that phase shipped. The manual skills (`/gsd:mempalace-recall`, `/gsd:mempalace-capture`) remain available for direct invocation independent of the loop. ## 16. Registration tax (per ADR-857 + repo checklists) @@ -262,12 +279,14 @@ Phases 1–5 ship value **before** ADR-857's phase-6 cutover (the skills are inv ## 17. Open questions -1. **Wing identity** — one wing per repo (`project_code`) vs one per milestone? Recommendation: per-repo wing, milestone/phase as KG validity windows + rooms; revisit if wings get too coarse. -2. **`replace` migration** — do we backfill existing `.planning/graphs/` into the palace KG, or only forward-fill? Recommendation: ship a one-shot `mempalace mine .planning/` + KG import as part of mode switch. -3. **Curator agent tier** — the curator is operational (branches, API calls, error recovery) ⇒ `sonnet` model. Confirm. -4. **Headless MCP availability** — verify MemPalace's stdio MCP server *is* reachable under `/gsd-autonomous`/cron, or commit fully to the CLI path there (FR-T1). -5. **Phase-6 dependency** — accept shipping 1–5 ahead of loop wiring, or hold until phase-6 lands? Recommendation: ship ahead; the manual-invocation value is real and de-risks phase-6. -6. **Diary `agent_name`** — namespace per GSD role (`gsd-orchestrator`) or per repo? Recommendation: per repo+role so diaries don't collide across projects. +Each open question is traced to the phase whose acceptance must **resolve** it (so a decision doesn't sit ownerless between phases): + +1. **Wing identity** _(resolve in **Phase 0** spike)_ — one wing per repo (`project_code`) vs one per milestone? Recommendation: per-repo wing, milestone/phase as KG validity windows + rooms; revisit if wings get too coarse. The Phase-0 spike gate ("recall surfaces real prior decisions") is where this is validated. +2. **`replace` migration** _(resolve in **Phase 4**)_ — do we backfill existing `.planning/graphs/` into the palace KG, or only forward-fill? Recommendation: ship a one-shot `mempalace mine .planning/` + KG import as part of mode switch. Owned by the Phase-4 "Modes" gate. +3. **Curator agent tier** _(resolve in **Phase 2**)_ — the curator is operational (branches, API calls, error recovery) ⇒ `sonnet` model. Confirm at Phase-2 agent delivery. +4. **Headless MCP availability** _(resolve in **Phase 5**)_ — verify MemPalace's stdio MCP server *is* reachable under `/gsd-autonomous`/cron, or commit fully to the CLI path there (FR-T1). Owned by the Phase-5 headless gate. +5. **Loop wiring** _(resolved — shipped)_ — ADR-857 is released and the host-loop workflows call `loop render-hooks`, so Phase-6 auto-fire is wired and verified end-to-end (§15.2). The manual skills (`/gsd:mempalace-recall`, `/gsd:mempalace-capture`) remain available for direct use. +6. **Diary `agent_name`** _(resolve in **Phase 6**)_ — namespace per GSD role (`gsd-orchestrator`) or per repo? Recommendation: per repo+role so diaries don't collide across projects. Owned by the Phase-6 curator wiring (UX-curator). --- diff --git a/docs/reference/capability-manifest.md b/docs/reference/capability-manifest.md index db971bda3..6ddae7076 100644 --- a/docs/reference/capability-manifest.md +++ b/docs/reference/capability-manifest.md @@ -143,6 +143,7 @@ Runtime capabilities describe how GSD projects its artefacts onto one host CLI. | Axis | Field | Type summary | |---|---|---| | Config home | `runtime.configHome` | Structured object with `kind` (`dot-home` \| `dot-home-nested` \| `xdg` \| `generic-agents-root`), `name`, optional `parent`, `env[]`, `probe[]`, `probeExists`, `skillsHome`. `probeExists` is an optional sub-path applied to probe candidates: for `generic-agents-root` it is a hard filter (a candidate qualifies only if `/` exists); for `dot-home-nested` it is a preference that makes probing pick the candidate GSD owns (e.g. `gsd-core/VERSION`) over a bare-existing sibling before falling back — see ADR-1016 and #213/#217. | +| Local config dir | `runtime.localConfigDir` | Required dot-prefixed string. The runtime's **local** content-rewrite directory — the `./` target GSD stamps into rewritten artefact bodies (e.g. `./.claude/` → `.//`) and the local install dir basename. Backs `getDirName()` (registry-derived, #1679). Usually `.` (the runtime's home dot-dir), but **three runtimes diverge** because they read GSD's content from a non-home directory: `copilot` → `.github` (GitHub Copilot reads custom instructions from `.github/copilot-instructions.md` / `.github/instructions/`; see `convertClaudeToCopilotContent` rewrites in `src/runtime-artifact-conversion.cts`), `antigravity` → `.agents` (local agent/workflow dir; see the antigravity rewrites in `src/runtime-artifact-conversion.cts`), `kimi` → `.kimi-code`. Distinct from `configHome.name` (the **global** install home, which for these three is `.copilot` / `antigravity` / `agents`). Byte-parity-proven against the prior hand-maintained mapping by the golden-install-parity harness. | | Config format | `runtime.configFormat` | Closed enum: `settings-json` \| `toml` \| `markdown` \| `markdown-dir` \| `none`. | | Artefact layout | `runtime.artifactLayout` | Object with `global` and `local` arrays of `ArtifactKind` (`kind`, `destSubpath`, `prefix`, `nesting`, `recursive`, `stage`). | | Command style | `runtime.commandStyle` | Closed enum: `slash-hyphen` \| `shell-var`. | diff --git a/docs/reference/host-integration-capability-matrix.md b/docs/reference/host-integration-capability-matrix.md new file mode 100644 index 000000000..3b6f06d08 --- /dev/null +++ b/docs/reference/host-integration-capability-matrix.md @@ -0,0 +1,582 @@ +# Host Integration Capability Matrix + +This document is the maintainer-facing source of truth for the `hostIntegration` block in every +`capabilities//capability.json` runtime descriptor. Every per-CLI axis value is either: + +- **documented** — backed by a cited authoritative source and evidence quote, or +- **`undocumented`** — the explicit fail-closed sentinel used when the CLI's public documentation + does not state a value for that axis. `undocumented` validates in the registry but never + propagates into effective axes: negotiation degrades closed to the safe default. + +Values are generated from per-CLI documentation research (Context7 + official docs). They are +consumed verbatim by `gen:capability-registry` and validated by `capability-validator.cjs`. + +--- + +## Axes legend + +| Axis | Meaning | +|---|---| +| `embeddingMode` | Whether the CLI exposes an in-process programmatic API (`imperative`) or integrates purely through configuration files (`declarative`). | +| `commandSurface` | How slash commands are registered: `slash-file` (markdown), `slash-toml` (TOML), `slash-programmatic` (code API), `palette`, `prose-only`. | +| `modelMode` | Whether extensions can programmatically request or supply a model (`active`) or select only by config (`passive`). | +| `hookBus` | Who owns the hook lifecycle: `host` (the CLI fires hooks), `engine` (VS Code/Electron extension host), `none`. | +| `stateIO` | Filesystem access model: `filesystem` (full local FS), `sandboxed-storage`, `session-log-append`. | +| `transport` | Integration transport: `mcp` (Model Context Protocol), `native-extension`. | +| `runtime` | Plugin/extension execution runtime: `node`, `bun`, `python`, `go`, `rust`, `electron`, `sandboxed-web`, `other`. | + +### dispatch sub-axes + +| Sub-axis | Meaning | +|---|---| +| `namedDispatch` | Whether agents can be invoked by name (true/false/`undocumented`). | +| `nested` | Whether subagents can themselves spawn subagents (true/false/`undocumented`). | +| `maxDepth` | Maximum nesting depth (integer; -1 = unbounded; `undocumented`). | +| `background` | Whether subagents can run asynchronously in the background (true/false/`undocumented`). | +| `subagentToolkit` | Tool surface available to subagents: `full`, `read-only`, or `undocumented`. | +| `backgroundDispatch` | Whether a BACKGROUND-dispatched sub-agent can itself spawn further named sub-agents — the #853 discriminator (true/false/`undocumented`). | + +### Interface points + +| Point | Meaning | +|---|---| +| `command` | Slash-command routing and invocation capability. | +| `dispatch` | Subagent/multi-agent dispatch capability. | +| `model` | Programmatic model selection capability. | +| `hooks` | Lifecycle hook registration capability. | +| `state` | Filesystem/state I/O capability. | +| `artifact` | Artifact delivery (skills, commands) surface capability. | + +--- + +## claude + +| Axis | Value | Source | Evidence | +|---|---|---|---| +| embeddingMode | imperative | https://code.claude.com/docs/en/agent-sdk/overview | "The Agent SDK offers hooks to execute custom code at critical points within the agent's lifecycle. These callback functions enable developer" | +| commandSurface | slash-file | https://code.claude.com/docs/en/agent-sdk/slash-commands | "Each custom command is a markdown file where the filename (without the `.md` extension) becomes the command name. The file content defines w" | +| modelMode | passive | https://code.claude.com/docs/en/agent-sdk/typescript | "setModel(model?: string): Changes the model (only available in streaming input mode) ... model overrides the default model for this subagent" | +| hookBus | host | https://code.claude.com/docs/en/agent-sdk/python | "HookEvent = Literal['PreToolUse', 'PostToolUse', 'PostToolUseFailure', 'UserPromptSubmit', 'Stop', 'SubagentStop', 'PreCompact', 'Notificati" | +| stateIO | filesystem | https://code.claude.com/docs/en/sandboxing | "The sandboxed Bash tool restricts file system access, granting read and write access to the current working directory and session temp direc" | +| transport | mcp | https://code.claude.com/docs/en/mcp | "Project-Scoped MCP Server Configuration in .mcp.json ... This JSON structure illustrates the format for a project-scoped MCP server configur" | +| runtime | node | https://code.claude.com/docs/en/agent-sdk/typescript | "import { query } from \"@anthropic-ai/claude-agent-sdk\"; ... pathToClaudeCodeExecutable (string) - Specifies the path to the Claude Code CLI" | +| dispatch.namedDispatch | true | https://code.claude.com/docs/en/agent-sdk/subagents | "agents: { 'code-reviewer': AgentDefinition({ description: 'Expert code reviewer.', ... }) } ... subagent_type: block.inp" | +| dispatch.nested | true | https://code.claude.com/docs/en/sub-agents | "As of Claude Code v2.1.172, a subagent can spawn its own subagents, allowing delegated tasks to split into parallel subt" | +| dispatch.maxDepth | 5 | https://code.claude.com/docs/en/sub-agents | "foreground subagents can spawn at any depth, blocking their parent until completion. Background subagents are limited to" | +| dispatch.background | true | https://code.claude.com/docs/en/sub-agents | "Subagents can run in the foreground, blocking the main conversation and passing permission prompts to you, or in the bac" | +| dispatch.subagentToolkit | full | https://code.claude.com/docs/en/sub-agents | "If all tools remain selected, the subagent inherits all tools available to the main conversation." | +| dispatch.backgroundDispatch | false | https://code.claude.com/docs/en/sub-agents | "Background subagents are limited to a depth of five and cannot spawn further, " | + +Sources consulted: +- https://code.claude.com/docs/en/sub-agents +- https://code.claude.com/docs/en/agent-sdk/slash-commands +- https://code.claude.com/docs/en/agent-sdk/subagents +- https://code.claude.com/docs/en/agent-sdk/python +- https://code.claude.com/docs/en/agent-sdk/typescript +- https://code.claude.com/docs/en/agent-sdk/overview +- https://code.claude.com/docs/en/mcp +- https://code.claude.com/docs/en/sandboxing +- Context7 /websites/code_claude +- Context7 /llmstxt/code_claude_llms_txt + +--- + +## codex + +> **Note:** ADR-1239's host matrix lists Codex as `prose-only`; current OpenAI Codex dev docs document slash-commands, so `commandSurface` is `slash-file` here (docs are the source of truth). + +| Axis | Value | Source | Evidence | +|---|---|---|---| +| embeddingMode | declarative | https://developers.openai.com/codex/plugins/build | "No in-process programmatic API exists. Plugins integrate through: External command execution (hooks), MCP server processes, Configuration fi" | +| commandSurface | slash-file | https://github.com/openai/codex/blob/main/codex-rs/core-skills/src/loader.rs | "const SKILLS_FILENAME: &str = \"SKILL.md\"; ... Each skill is a folder with a SKILL.md file containing YAML frontmatter with name and descript" | +| modelMode | passive | https://github.com/openai/codex/blob/main/codex-rs/config/src/config_toml.rs | "pub model_provider: Option ... model is selected by config field; no programmatic model request API" | +| hookBus | host | https://github.com/openai/codex/blob/main/codex/codex-rs/hooks/src/lib.rs | "pub const HOOK_EVENT_NAMES: [&str; 10] = [\"PreToolUse\", \"PermissionRequest\", \"PostToolUse\", \"PreCompact\", \"PostCompact\", \"SessionStart\", \"Us" | +| stateIO | filesystem | https://developers.openai.com/codex/concepts/sandboxing | "workspace-write: The default mode allowing Codex to read files, edit within the workspace, and run routine local commands inside that bounda" | +| transport | mcp | https://github.com/openai/codex/blob/main/codex-rs/config/src/config_toml.rs | "pub mcp_servers: HashMap ... Definition for MCP servers that Codex can reach out to for tool calls." | +| runtime | node | https://github.com/openai/codex/blob/main/codex-cli/package.json | "\"engines\": {\"node\": \">=16\"} ... The npm-distributed CLI wrapper is a Node.js script (#!/usr/bin/env node)" | +| dispatch.namedDispatch | true | https://github.com/openai/codex/blob/main/codex-rs/core/src/tools/handlers/multi_agents_spec.rs | "\"agent_type\".to_string(), JsonSchema::string(Some(agent_type_description.to_string())) ... apply_role_to_config(&mut con" | +| dispatch.nested | true | https://developers.openai.com/codex/multi-agent | "agents.max_depth defaults to 1, which allows a direct child agent to spawn but prevents deeper nesting." | +| dispatch.maxDepth | 1 | https://developers.openai.com/codex/config-reference | "agents.max_depth: Maximum nesting depth allowed for spawned agent threads (root sessions start at depth 0; default: 1)" | +| dispatch.background | true | https://github.com/openai/codex/blob/main/codex-rs/core/src/tools/handlers/multi_agents_spec.rs | "spawn_agent returns the spawned agent id immediately; a separate wait_agent tool polls for final status." | +| dispatch.subagentToolkit | full | https://developers.openai.com/codex/multi-agent | "Subagents inherit the sandbox policy and tool surface from the parent session." | +| dispatch.backgroundDispatch | true | https://github.com/openai/codex/blob/main/codex-rs/core/templates/collab/experimental_prompt.md | "Sub-agents have access to the same set of tools as you do so you must tell them if they are allowed to spawn sub-agents themselves or not." The config (codex-rs/config/src/config_toml.rs) exposes an | + +Sources consulted: +- https://github.com/openai/codex (repo via gh CLI) +- /openai/codex (Context7 library ID) +- https://github.com/openai/codex/blob/main/codex-rs/config/src/config_toml.rs +- https://github.com/openai/codex/blob/main/codex-rs/core/src/tools/handlers/multi_agents_spec.rs +- https://github.com/openai/codex/blob/main/codex-rs/core-skills/src/loader.rs +- https://developers.openai.com/codex/config-reference +- https://developers.openai.com/codex/plugins/build +- https://developers.openai.com/codex/multi-agent +- https://developers.openai.com/codex/cli/slash-commands + +Documentation gaps: +- dispatch.maxDepth is configurable (Option with no documented upper bound); the documented default is 1 but the actual enforced maximum is not stated. +- dispatch.subagentToolkit: docs say subagents 'inherit the tool surface' but do not enumerate whether any tools are excluded. +- runtime: the Node.js entry point is a thin launcher shim; the actual agent execution runtime is a compiled Rust binary — axis classification is ambiguous. + +--- + +## gemini + +| Axis | Value | Source | Evidence | +|---|---|---|---| +| embeddingMode | declarative | https://github.com/google-gemini/gemini-cli/blob/main/packages/sdk/SDK_DESIGN.md | "This feature is currently not implemented. (repeated for both extension and subagent SDK APIs; all actual integration is via files: TOML com" | +| commandSurface | slash-toml | https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/custom-commands.md | "Custom commands in Gemini CLI are defined in TOML format with the .toml file extension … Commands are invoked as slash commands in the CLI." | +| modelMode | passive | https://github.com/google-gemini/gemini-cli/blob/main/docs/hooks/reference.md | "BeforeModel Hook: Fires before sending a request to the LLM … hookSpecificOutput.llm_request (object) — An object that overrides parts of th" | +| hookBus | host | https://github.com/google-gemini/gemini-cli/blob/main/docs/hooks/reference.md | "Hooks function as host-fired events … The CLI fires events at predetermined lifecycle points [BeforeAgent, AfterAgent, BeforeTool, AfterTool" | +| stateIO | filesystem | https://github.com/google-gemini/gemini-cli/blob/main/docs/reference/configuration.md | "Local access by default with gitignore/geminiignore respect … Set to a boolean to enable or disable the sandbox" | +| transport | mcp | https://github.com/google-gemini/gemini-cli/blob/main/docs/tools/mcp-server.md | "Configure a Node.js MCP server using stdio … { \"mcpServers\": { \"nodeServer\": { \"command\": \"node\", \"args\": [\"dist/server.js\"] } } }" | +| runtime | node | https://github.com/google-gemini/gemini-cli/blob/main/docs/reference/configuration.md | "References to node-pty and child_process indicate JavaScript/Node.js execution environment" | +| dispatch.namedDispatch | true | https://github.com/google-gemini/gemini-cli/blob/main/docs/core/subagents.md | "Example of a custom subagent definition file (.gemini/agents/security-auditor.md) … name: security-auditor" | +| dispatch.nested | false | https://github.com/google-gemini/gemini-cli/blob/main/docs/core/subagents.md | "Each subagent operates in an isolated context loop … This isolation also includes recursion protection, preventing subag" | +| dispatch.maxDepth | 1 | https://github.com/google-gemini/gemini-cli/blob/main/docs/core/subagents.md | "To prevent infinite loops and excessive token usage, subagents cannot call other subagents. … The architecture enforces" | +| dispatch.background | undocumented | no authoritative doc — searched: https://github.com/google-gemini/gemini-cli/blob/main/docs/core/subagents.md, /google-gemini/gemini-cli (Context7 library) | — | +| dispatch.subagentToolkit | undocumented | no authoritative doc — searched: https://github.com/google-gemini/gemini-cli/blob/main/docs/core/subagents.md, /google-gemini/gemini-cli (Context7 library) | — | +| dispatch.backgroundDispatch | false | https://raw.githubusercontent.com/google-gemini/gemini-cli/main/docs/core/subagents.md | "To prevent infinite loops and excessive token usage, subagents cannot call other subagents." Additionally: "If a subagent is granted the `*` tool wildcard, it will still be unable to see or invoke ot | + +Sources consulted: +- https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/custom-commands.md +- https://github.com/google-gemini/gemini-cli/blob/main/docs/core/subagents.md +- https://github.com/google-gemini/gemini-cli/blob/main/docs/hooks/reference.md +- https://github.com/google-gemini/gemini-cli/blob/main/docs/reference/configuration.md +- https://github.com/google-gemini/gemini-cli/blob/main/docs/tools/mcp-server.md +- https://github.com/google-gemini/gemini-cli/blob/main/packages/sdk/SDK_DESIGN.md +- /google-gemini/gemini-cli (Context7 library) + +Documentation gaps: +- dispatch.background — docs describe subagents as operating in isolated context loops but do not state whether they execute synchronously or asynchronously. +- dispatch.subagentToolkit — subagents have a configurable tool grant model but no single fixed value of 'full' or 'read-only' is stated as the architecture-level constraint. + +--- + +## opencode + +| Axis | Value | Source | Evidence | +|---|---|---|---| +| embeddingMode | imperative | https://opencode.ai/docs/plugins | "Plugins are JavaScript/TypeScript modules that export plugin functions; they register hooks via `import type { Plugin } from '@opencode-ai/p'" | +| commandSurface | slash-file | https://opencode.ai/docs/commands | "\"Create markdown files in the `commands/` directory to define custom commands.\" and \"The markdown file name becomes the command name." | +| modelMode | active | /anomalyco/opencode (Context7) — packages/plugin/src/v2/promise/README.md | "`ctx.aisdk.sdk(async (event) => { ... event.sdk = mod.createXai(event.options) })` and `ctx.aisdk.language((event) => { ... event.language =" | +| hookBus | host | https://opencode.ai/docs/plugins | "Host fires events including: `tool.execute.before`, `tool.execute.after`, `session.created`, `session.compacted`, `session.deleted`" | +| stateIO | filesystem | https://opencode.ai/docs/plugins | "Plugin context includes `directory` (working directory path), `worktree` (git worktree path), and `$` (\"Bun's shell API\")" | +| transport | mcp | https://opencode.ai/docs/mcp-servers | "\"OpenCode supports both local and remote servers.\" and \"Once added, MCP tools are automatically available to the LLM\"" | +| runtime | bun | https://opencode.ai/docs/plugins | "\"$\": Bun's shell API for executing commands\" (plugin context property); \"OpenCode runs `bun install` at startup\"" | +| dispatch.namedDispatch | true | https://opencode.ai/docs/agents | "\"Subagents can be invoked: Automatically by primary agents for specialized tasks based on their descriptions. Manually b" | +| dispatch.nested | undocumented | no authoritative doc — searched: https://opencode.ai/docs/agents | — | +| dispatch.maxDepth | undocumented | no authoritative doc — searched: https://opencode.ai/docs/agents | — | +| dispatch.background | false | https://github.com/sst/opencode/issues/5887 | "\"Currently, sub-agent delegation in `opencode` appears to be synchronous or modal... There is no native 'fire-and-forget'" | +| dispatch.subagentToolkit | full | https://opencode.ai/docs/agents | "The 'general' subagent \"Has full tool access (except todo), so it can make file changes when needed.\"" | +| dispatch.backgroundDispatch | undocumented | no authoritative doc — https://github.com/anomalyco/opencode/issues/18100 and https://github.com/anomalyco/opencode/blob/dev/opencode/packages/opencode/src/tool/task.ts | Opencode supports background task dispatch via the Task tool's `background: true` parameter but whether a background-spawned agent can itself spawn further sub-agents is not documented. | + +Sources consulted: +- https://opencode.ai/docs/plugins +- https://opencode.ai/docs/agents +- https://opencode.ai/docs/commands +- https://opencode.ai/docs/mcp-servers +- /websites/opencode_ai_plugins (Context7) +- /anomalyco/opencode (Context7) +- https://github.com/sst/opencode/issues/5887 + +Documentation gaps: +- dispatch.nested +- dispatch.maxDepth + +--- + +## cursor + +| Axis | Value | Source | Evidence | +|---|---|---|---| +| embeddingMode | imperative | https://cursor.com/docs/sdk/typescript | "local.customTools where you define tool functions that execute 'in your process, so it can reach anything your code can'; Agent.create()" | +| commandSurface | slash-file | https://cursor.com/docs/enterprise/llm-safety-and-controls | "Commands are reusable prompts invoked via slash commands (e.g., /test), while workflows enable multi-step processes" | +| modelMode | passive | https://cursor.com/docs/sdk/python | "The model used for a run can be overridden by passing a ModelSelection object in SendOptions to agent.send()." | +| hookBus | host | https://cursor.com/docs/hooks | "Agent hooks: sessionStart, sessionEnd, preToolUse, postToolUse, subagentStart, subagentStop, beforeShellExecution, afterShellExecution" | +| stateIO | filesystem | https://cursor.com/docs/reference/sandbox | "Local agents run with sandbox options disabled by default." | +| transport | mcp | https://cursor.com/docs/mcp | "The Model Context Protocol (MCP) allows Cursor to connect to external tools and data sources." | +| runtime | node | https://cursor.com/docs/sdk/typescript | "The SDK runs on Node.js. It requires Node.js 22.13 or later and is described as a Node-first package." | +| dispatch.namedDispatch | true | https://cursor.com/docs/subagents | "Invoke specific subagents using slash commands in your prompt. This allows for direct control over which agent performs" | +| dispatch.nested | true | https://cursor.com/docs/sdk/typescript | "The top-level agent and its direct subagents can launch subagents, but a subagent launched by another subagent can't lau" | +| dispatch.maxDepth | 2 | https://cursor.com/docs/sdk/typescript | "The top-level agent and its direct subagents can launch subagents, but a subagent launched by another subagent can't lau" | +| dispatch.background | true | https://cursor.com/docs/subagents | "Background, which returns immediately while the subagent works independently, best for long-running tasks or parallel wo" | +| dispatch.subagentToolkit | full | https://cursor.com/docs/subagents | "Subagents can utilize MCP tools, inheriting all tools available to their parent agent, including those from configured s" | +| dispatch.backgroundDispatch | true | https://cursor.com/docs/subagents (FAQ: Can subagents launch other subagents?) and https://cursor.com/docs/sdk/typescript (Subagents > Nested subagents) | FAQ: "As of Cursor 2.5, subagents have the capability to launch child subagents, enabling the creation of a hierarchical structure for coordinated tasks. This nested launching functionality requires T | + +Sources consulted: +- https://cursor.com/docs/subagents +- https://cursor.com/docs/hooks +- https://cursor.com/docs/sdk/typescript +- https://cursor.com/docs/sdk/python +- https://cursor.com/docs/mcp +- https://cursor.com/docs/reference/sandbox +- https://cursor.com/docs/enterprise/llm-safety-and-controls +- /websites/cursor (Context7) + +--- + +## cline + +| Axis | Value | Source | Evidence | +|---|---|---|---| +| embeddingMode | imperative | /cline/cline (Context7) — https://github.com/cline/cline/blob/main/docs/sdk/plugins.mdx | "Implement the AgentPlugin interface to register tools, hooks, and configuration. The setup function is used for registering capabilities." | +| commandSurface | slash-file | /cline/cline (Context7) — https://github.com/cline/cline/blob/main/cline/apps/vscode/src/test/slash-commands.test.ts | "workflow markdown files (with .md, .markdown, or .txt extensions) are invoked as slash commands using their filename." | +| modelMode | active | /cline/cline (Context7) — https://github.com/cline/cline/blob/main/sdk/packages/llms/README.md | "The Runtime API, accessible via createLlmsRuntime(...), allows for the creation of a registry that manages configured providers and their de" | +| hookBus | host | /cline/cline (Context7) — https://github.com/cline/cline/blob/main/sdk/README.md | "Package agent capabilities as extensions (plugins) that can register tools, observe lifecycle events, and modify agent behavior." | +| stateIO | filesystem | /cline/cline (Context7) — https://github.com/cline/cline/blob/main/cline/sdk/packages/shared/src/storage/paths.ts | "resolveClineDir() returns ~/.cline; resolveDocumentsExtensionPath('Workflows') returns ~/Documents/Cline/Workflows." | +| transport | mcp | /cline/cline (Context7) — https://github.com/cline/cline/blob/main/docs/mcp/mcp-overview.mdx | "MCP (Model Context Protocol) enables Cline to interact with external tools and data sources" | +| runtime | node | /cline/cline (Context7) — https://github.com/cline/cline/blob/main/sdk/examples/plugins/typescript-lsp/README.md | "Installs a portable subagent plugin ... cp examples/plugins/agents-squad/index.ts ~/.cline/plugins/portable-subagents.ts." | +| dispatch.namedDispatch | true | /cline/cline (Context7) — https://github.com/cline/cline/blob/main/sdk/examples/plugins/agents-squad/README.md | "parent → start_subagent(preset: \"phantom\", task: \"Map the auth module\") → phantom: save_handoff(...)" | +| dispatch.nested | false | /cline/cline (Context7) — https://github.com/cline/cline/blob/main/docs/features/subagents.mdx | "subagents are restricted from editing files, using the browser, accessing MCP servers, or creating nested subagents." | +| dispatch.maxDepth | 1 | /cline/cline (Context7) — https://github.com/cline/cline/blob/main/docs/features/subagents.mdx | "They are explicitly prohibited from ... spawning other subagents." | +| dispatch.background | true | /cline/cline (Context7) — https://github.com/cline/cline/blob/main/docs/features/subagents.mdx | "Commands executed by subagents run in the background and are strictly limited to read-only operations" | +| dispatch.subagentToolkit | read-only | /cline/cline (Context7) — https://github.com/cline/cline/blob/main/docs/features/subagents.mdx | "Subagents are equipped with tools for read-only operations, including reading file contents (read_file), listing directo" | +| dispatch.backgroundDispatch | false | https://docs.cline.bot/features/subagents (mirrored at https://github.com/cline/cline/blob/main/docs/features/subagents.mdx) | "They cannot edit files, use the browser, or spawn nested subagents" — and from the GitHub source: "subagents are restricted from editing files, using the browser, accessing MCP servers, or creating n | + +Sources consulted: +- https://github.com/cline/cline/blob/main/docs/sdk/plugins.mdx +- https://github.com/cline/cline/blob/main/sdk/README.md +- https://github.com/cline/cline/blob/main/sdk/packages/agents/README.md +- https://github.com/cline/cline/blob/main/sdk/examples/plugins/agents-squad/README.md +- https://github.com/cline/cline/blob/main/docs/features/subagents.mdx +- https://github.com/cline/cline/blob/main/docs/mcp/mcp-overview.mdx +- https://github.com/cline/cline/blob/main/sdk/packages/llms/README.md +- /cline/cline (Context7) + +--- + +## hermes + +| Axis | Value | Source | Evidence | +|---|---|---|---| +| embeddingMode | imperative | https://hermes-agent.nousresearch.com/docs/guides/build-a-hermes-plugin | "ctx.register_tool() puts your tool in the registry — the model sees it immediately" | +| commandSurface | slash-programmatic | https://hermes-agent.nousresearch.com/docs/guides/build-a-hermes-plugin | "ctx.register_command('mystatus', handler=_handle_status, description='Show plugin status') — The command appears in autocomplete, /help output" | +| modelMode | active | https://hermes-agent.nousresearch.com/docs/guides/build-a-hermes-plugin | "register_provider(ProviderProfile(name=..., aliases=(...), display_name=..., env_vars=(...), base_url=..., auth_type=..., default_aux_model=" | +| hookBus | host | https://hermes-agent.nousresearch.com/docs/user-guide/features/hooks | "Hermes owns and manages the entire hook infrastructure. At runtime, HookRegistry.discover_and_load() scans ~/.hermes/hooks/" | +| stateIO | filesystem | https://hermes-agent.nousresearch.com/docs/user-guide/configuration | "The agent has the same filesystem access as your user account." | +| transport | mcp | https://hermes-agent.nousresearch.com/docs/user-guide/features/mcp | "MCP support ships with the standard install — no extra step needed." | +| runtime | python | Context7 /nousresearch/hermes-agent | "The plugin and agent runtime is Python (confirmed by register(ctx) in __init__.py, importlib.import_module, run_agent.py, tools/registry.py)" | +| dispatch.namedDispatch | false | https://hermes-agent.nousresearch.com/docs/user-guide/features/delegation | "The documentation contains no mention of named agents. Subagents are identified only by role ('leaf' or 'orchestrator')" | +| dispatch.nested | true | /nousresearch/hermes-agent (Context7) — configuration.md | "max_spawn_depth: 1 — Delegation tree depth cap (1-3, clamped). 1 = flat (default): parent spawns leaves that cannot dele" | +| dispatch.maxDepth | 1 | /nousresearch/hermes-agent (Context7) — configuration.md | "max_spawn_depth: 1 # Delegation tree depth cap (1-3, clamped). 1 = flat (default): parent spawns leaves that cannot dele" | +| dispatch.background | true | https://github.com/NousResearch/hermes-agent/releases/tag/v2026.6.19 | "delegate_task(background=true) dispatches a subagent that runs in the background and returns a handle immediately" | +| dispatch.subagentToolkit | read-only | https://hermes-agent.nousresearch.com/docs/guides/delegation-patterns | "Nested delegation is opt-in; by default, leaf subagents cannot call delegate_task, clarify, memory, send_message, or exe" | +| dispatch.backgroundDispatch | false | https://github.com/nousresearch/hermes-agent/blob/main/website/docs/user-guide/features/delegation.md (via Context7 query of /nousresearch/hermes-agent) | "Nested delegation is an opt-in feature, requiring role=\"orchestrator\" for children and an increased max_spawn_depth from its default of 1. It can also be globally disabled with orchestrator_enabled | + +Sources consulted: +- https://hermes-agent.nousresearch.com/docs/user-guide/features/delegation +- https://hermes-agent.nousresearch.com/docs/user-guide/features/hooks +- https://hermes-agent.nousresearch.com/docs/user-guide/features/mcp +- https://hermes-agent.nousresearch.com/docs/user-guide/configuration +- https://hermes-agent.nousresearch.com/docs/guides/build-a-hermes-plugin +- https://hermes-agent.nousresearch.com/docs/guides/delegation-patterns +- https://github.com/NousResearch/hermes-agent/releases/tag/v2026.6.19 +- /nousresearch/hermes-agent (Context7) + +Documentation gaps: +- runtime — Hermes plugins and agent core run in Python, but this was confirmed by code inspection rather than explicit docs statement. +- dispatch.namedDispatch — docs explicitly confirm no named-agent dispatch in delegate_task; Kanban has named profiles but that is a separate board system not a dispatch mechanism. + +--- + +## antigravity + +| Axis | Value | Source | Evidence | +|---|---|---|---| +| embeddingMode | declarative | https://github.com/alphaperseii3000/google-antigravity-docs/blob/master/google-antigravity-docs.md | "Skills require a SKILL.md file; Workflows are saved as markdown files; Rules are manually defined constraints — all configuration-file-based" | +| commandSurface | slash-file | https://github.com/alphaperseii3000/google-antigravity-docs/blob/master/google-antigravity-docs.md | "Workflows are saved as markdown files, providing a repeatable method for executing key processes. They can be invoked in the Agent using a s" | +| modelMode | passive | https://dev.to/arindam_1729/antigravity-cli-a-hands-on-guide-to-googles-terminal-coding-agent-5bc7 | "Selection occurs via `-m` flag or `/model` command inside the TUI. No programmatic model request API is documented for extensions/skills" | +| hookBus | host | https://www.aibuilderclub.com/blog/antigravity-cli-guide | "The CLI fires hooks, not the engine. These are JSON lifecycle interceptors (before tool call, after file edit, on session start)." | +| stateIO | filesystem | https://www.explainx.ai/blog/antigravity-cli-features-sandbox-plugins-subagents-2026 | "Plugin staging at ~/.gemini/antigravity-cli/plugins//; skills at ~/.gemini/antigravity-cli/skills/" | +| transport | mcp | https://dev.to/arindam_1729/antigravity-cli-a-hands-on-guide-to-googles-terminal-coding-agent-5bc7 | "Both local (stdio) and remote (HTTP) Model Context Protocol servers are supported" | +| runtime | go | https://developers.googleblog.com/an-important-update-transitioning-gemini-cli-to-antigravity-cli/ | "Built in Go, Antigravity CLI is snappier and more responsive." | +| dispatch.namedDispatch | undocumented | no authoritative doc — searched: https://www.aibuilderclub.com/blog/antigravity-cli-guide, https://antigravity.google/docs/agents | — | +| dispatch.nested | undocumented | no authoritative doc — searched: https://antigravity.google/docs/agents | — | +| dispatch.maxDepth | undocumented | no authoritative doc — searched: https://antigravity.google/docs/agents | — | +| dispatch.background | true | https://developers.googleblog.com/an-important-update-transitioning-gemini-cli-to-antigravity-cli/ | "Antigravity CLI orchestrates multiple agents for complex tasks in the background" | +| dispatch.subagentToolkit | undocumented | no authoritative doc — searched: https://www.explainx.ai/blog/antigravity-cli-features-sandbox-plugins-subagents-2026 | — | +| dispatch.backgroundDispatch | undocumented | no authoritative doc — Multiple sources consulted: antigravity.google/docs/cli-subagents (returned blank/JS-rendered), antigravity.google/docs/agent (blank), github.com/google-antigravity/antigravity-cli README, Context7 /google-antigravity/antigravity-cli | All documentation consulted describes a two-level orchestrator→subagent architecture. Background subagents run asynchronously while the main agent continues accepting prompts. The DataCamp tutorial st | + +Sources consulted: +- https://github.com/alphaperseii3000/google-antigravity-docs/blob/master/google-antigravity-docs.md +- https://developers.googleblog.com/an-important-update-transitioning-gemini-cli-to-antigravity-cli/ +- https://dev.to/arindam_1729/antigravity-cli-a-hands-on-guide-to-googles-terminal-coding-agent-5bc7 +- https://www.explainx.ai/blog/antigravity-cli-features-sandbox-plugins-subagents-2026 +- https://www.aibuilderclub.com/blog/antigravity-cli-guide +- https://antigravity.google/docs/agents +- https://antigravity.google/docs/hooks + +Documentation gaps: +- dispatch.namedDispatch — docs describe dynamic plain-English goal dispatch where agent names subagents at runtime; no pre-registered named sub-agent API documented. +- dispatch.nested — no documentation found on whether subagents can themselves spawn further subagents. +- dispatch.maxDepth — no documented depth limit or explicit unbounded statement found. +- dispatch.subagentToolkit — docs describe a permissions approval model but do not explicitly state 'full' vs 'read-only' toolkit scope for subagents. + +--- + +## augment + +| Axis | Value | Source | Evidence | +|---|---|---|---| +| embeddingMode | declarative | https://docs.augmentcode.com/cli/plugins | "Plugins can provide several types of components, including Custom Commands defined in Markdown files within the `commands/` directory... Hoo" | +| commandSurface | slash-file | https://docs.augmentcode.com/cli/plugins | "Slash commands are Markdown files in the `commands/` directory. The filename becomes the command name" | +| modelMode | passive | https://docs.augmentcode.com/cli/subagents | "| model | No | Model to use for the agent. If not specified, the CLI default model is used." | +| hookBus | host | https://docs.augmentcode.com/cli/hooks | "Hook event types: PreToolUse (before a tool executes), PostToolUse (immediately after a tool completes), Stop (when the agent stops respondi" | +| stateIO | filesystem | https://github.com/augmentcode/auggie | "Node.js 22+ required. Hook configurations use `${AUGMENT_PLUGIN_ROOT}`" | +| transport | mcp | https://docs.augmentcode.com/cli/plugins | "Auggie supports a plugin system that allows you to extend its functionality with... MCP server integrations." | +| runtime | node | https://github.com/augmentcode/auggie | "Node.js 22+ required" | +| dispatch.namedDispatch | true | https://docs.augmentcode.com/cli/subagents | "| **name** | Yes | Name of the agent | ... you can trigger it by sending a message that references the agent name." | +| dispatch.nested | undocumented | no authoritative doc — searched: https://docs.augmentcode.com/cli/subagents | — | +| dispatch.maxDepth | undocumented | no authoritative doc — searched: https://docs.augmentcode.com/cli/subagents | — | +| dispatch.background | true | https://docs.augmentcode.com/cli/subagents | "Subagents run in parallel with other subagents... will show a summary of their current progress in the main thread." | +| dispatch.subagentToolkit | full | https://docs.augmentcode.com/cli/subagents | "If neither [tools nor disabled_tools] is specified, the subagent has access to all tools (default behavior)." | +| dispatch.backgroundDispatch | undocumented | no authoritative doc — https://docs.augmentcode.com/cosmos/automations | The Augment Code (Cosmos) docs describe workers as 'sub-agents launched mid-session by a manager Expert using the worker-launch command. Each worker is its own session with its own messages and permis | + +Sources consulted: +- https://docs.augmentcode.com/cli/plugins +- https://docs.augmentcode.com/cli/hooks +- https://docs.augmentcode.com/cli/subagents +- https://docs.augmentcode.com/cli/sdk-typescript +- https://docs.augmentcode.com/setup-augment/mcp +- https://github.com/augmentcode/auggie +- /llmstxt/augmentcode_llms-full_txt (Context7) + +Documentation gaps: +- dispatch.nested +- dispatch.maxDepth + +--- + +## qwen + +| Axis | Value | Source | Evidence | +|---|---|---|---| +| embeddingMode | imperative | https://qwenlm.github.io/qwen-code-docs/en/developers/channel-plugins | "Your entry point exports a ChannelPlugin object... this.registerCommand('mycommand', async (envelope, args) => { ... }); ... plugins load at startup as extensions." | +| commandSurface | slash-file | https://qwenlm.github.io/qwen-code-docs/en/users/extension/introduction | "Extensions can provide custom commands by placing Markdown files in a commands/ subdirectory" | +| modelMode | passive | https://qwenlm.github.io/qwen-code-docs/en/developers/channel-plugins | "The documentation does not expose a direct API for plugins to invoke the LLM or model directly." | +| hookBus | host | https://qwenlm.github.io/qwen-code-docs/en/users/features/hooks | "Qwen Code provides 14 distinct hook events: PreToolUse, PostToolUse, PostToolUseFailure, UserPromptSubmit, SessionStart, SessionEnd, Stop" | +| stateIO | filesystem | https://qwenlm.github.io/qwen-code-docs/en/developers/channel-plugins | "Runtime Environment: Node.js only. The architecture uses standard Node.js APIs: import, async/await, file I/O (writeFileSync), OS utilities" | +| transport | mcp | https://qwenlm.github.io/qwen-code-docs/en/developers/tools/mcp-server | "Qwen Code integrates with MCP servers through a sophisticated discovery and execution system" | +| runtime | node | https://qwenlm.github.io/qwen-code-docs/en/developers/channel-plugins | "Language: Node.js (TypeScript/JavaScript). Execution model: In-process — plugins load at startup as extensions." | +| dispatch.namedDispatch | true | https://qwenlm.github.io/qwen-code-docs/en/users/features/sub-agents/ | "Named subagents are invoked when the AI identifies tasks matching their specialization... Users can also explicitly requ" | +| dispatch.nested | false | https://qwenlm.github.io/qwen-code-docs/en/users/features/sub-agents/ | "Fork children cannot create further forks. This is enforced at runtime — if a fork attempts to spawn another fork, it re" | +| dispatch.maxDepth | 1 | https://qwenlm.github.io/qwen-code-docs/en/users/features/sub-agents/ | "Fork children cannot create further forks. This is enforced at runtime" | +| dispatch.background | true | https://qwenlm.github.io/qwen-code-docs/en/users/features/sub-agents/ | "Runs in background, parent continues immediately... Forks run parallel to the parent; the main conversation continues im" | +| dispatch.subagentToolkit | full | https://qwenlm.github.io/qwen-code-docs/en/users/features/sub-agents/ | "When omitted, the subagent inherits all available tools from the parent session." | +| dispatch.backgroundDispatch | false | https://qwenlm.github.io/qwen-code-docs/en/users/features/sub-agents/ (official Qwen Code documentation, 'Subagents' user guide page) and https://qwenlm.github.io/qwen-code-docs/en/design/fork-subagent/fork-subagent-design (Qwen Code fork-subagent design document, section '4. Recursive Fork Prevention') | The official user-facing Qwen Code docs state verbatim: "Fork children cannot create further forks. If a fork attempts spawning another fork, it receives an error instructing direct task execution ins | + +Sources consulted: +- https://qwenlm.github.io/qwen-code-docs/en/developers/channel-plugins +- https://qwenlm.github.io/qwen-code-docs/en/users/features/sub-agents/ +- https://qwenlm.github.io/qwen-code-docs/en/users/features/hooks +- https://qwenlm.github.io/qwen-code-docs/en/users/extension/introduction +- https://qwenlm.github.io/qwen-code-docs/en/developers/tools/mcp-server +- /websites/qwenlm_github_io_qwen-code-docs_en (Context7) +- /qwenlm/qwen-code (Context7) + +Documentation gaps: +- dispatch.nested — docs only restrict fork-type sub-agents from nesting; whether named sub-agents can themselves spawn named sub-agents is not stated. +- dispatch.maxDepth — depth=1 is documented only for fork sub-agents; depth for named sub-agent chains is undocumented. + +--- + +## codebuddy + +| Axis | Value | Source | Evidence | +|---|---|---|---| +| embeddingMode | declarative | https://www.codebuddy.ai/docs/cli/plugins-reference | "Commands are 'plain Markdown file[s]' located in commands/ by default ... a skill is a directory containing a SKILL.md ... The documentation" | +| commandSurface | slash-file | https://www.codebuddy.ai/docs/cli/plugins-reference | "Commands are 'plain Markdown file[s]' located in commands/ by default ... Skills are prefixed with this (e.g., /my-first-plugin:hello)" | +| modelMode | passive | https://www.codebuddy.ai/docs/cli/sdk | "The SDK is not for building plugins that run inside CodeBuddy. It's an external SDK for standalone applications" | +| hookBus | host | https://www.codebuddy.ai/docs/cli/hooks | "Full support for the hook event family (27+ events), covering tool lifecycle (PreToolUse / PostToolUse / PostToolUseFailure)" | +| stateIO | filesystem | https://www.codebuddy.ai/docs/cli/settings | "Storage operates in non-sandboxed mode by default ... Default: Full filesystem access governed by permission rules" | +| transport | mcp | https://www.codebuddy.ai/docs/cli/cli-reference | "MCP (Model Context Protocol) is built-in as a core feature ... codebuddy mcp command to 'Configure Model Context Protocol (MCP) servers'" | +| runtime | node | https://www.codebuddy.ai/docs/cli/sdk | "TypeScript/JavaScript: Node.js >= 18.20 ... npm install @tencent-ai/agent-sdk" | +| dispatch.namedDispatch | true | https://www.codebuddy.ai/docs/cli/sub-agents | "Sub-agents can be invoked explicitly by name: 'Request a specific sub-agent by mentioning it in your command'" | +| dispatch.nested | false | https://www.codebuddy.ai/docs/cli/sub-agents | "This prevents infinite nesting of agents (sub-agents cannot spawn other sub-agents)" | +| dispatch.maxDepth | 1 | https://www.codebuddy.ai/docs/cli/sub-agents | "The architecture enforces exactly one level of nesting — only the main CodeBuddy Code instance can invoke sub-agents." | +| dispatch.background | true | https://www.codebuddy.ai/docs/cli/sub-agents | "Launch a background agent using the run_in_background: true parameter ... Tasks return immediately with an ID" | +| dispatch.subagentToolkit | full | https://www.codebuddy.ai/docs/cli/sub-agents | "By default, sub-agents inherit all tools when the tools field is omitted ... Sub-agents can access MCP tools from config" | +| dispatch.backgroundDispatch | false | https://www.codebuddy.ai/docs/cli/sub-agents | "This prevents infinite nesting of agents (sub-agents cannot spawn other sub-agents)" — the restriction is stated as universal in the Sub-Agents documentation page. The daemon/background docs (https:/ | + +Sources consulted: +- https://www.codebuddy.ai/docs/cli/plugins +- https://www.codebuddy.ai/docs/cli/plugins-reference +- https://www.codebuddy.ai/docs/cli/sub-agents +- https://www.codebuddy.ai/docs/cli/hooks +- https://www.codebuddy.ai/docs/cli/sdk +- https://www.codebuddy.ai/docs/cli/settings +- /websites/codebuddy_cn (Context7) + +--- + +## copilot + +| Axis | Value | Source | Evidence | +|---|---|---|---| +| embeddingMode | declarative | https://docs.github.com/en/copilot/concepts/agents/copilot-cli/comparing-cli-features | "Declarative elements include custom instructions, skills, custom agents, and plugin configurations—all defined through configuration files" | +| commandSurface | slash-file | https://docs.github.com/en/copilot/concepts/agents/copilot-cli/comparing-cli-features | "Skills: Markdown files with instructions for specific contexts. Users can invoke via slash commands (e.g., /Markdown-Checker check README.md)" | +| modelMode | passive | https://github.com/github/copilot-sdk/blob/main/docs/auth/byok.md | "Model selection via config: model: 'gpt-4.1', provider: { type: 'openai', ... }." | +| hookBus | host | https://docs.github.com/en/copilot/reference/hooks-reference | "Hooks allow you to extend and customize the behavior of GitHub Copilot agents by executing custom shell commands at key points during agent" | +| stateIO | filesystem | https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/add-mcp-servers | "Configuration file Location: ~/.copilot/mcp-config.json. Hook config files stored in .github/hooks/*.json" | +| transport | mcp | https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/add-mcp-servers | "Copilot CLI comes with the GitHub MCP server already configured. STDIO is the standard transport." | +| runtime | undocumented | no authoritative doc — searched: https://github.com/github/copilot-cli/blob/main/README.md, https://github.com/github/copilot-sdk/blob/main/nodejs/README.md | — | +| dispatch.namedDispatch | true | https://github.com/github/copilot-sdk/blob/main/docs/features/custom-agents.md | "A custom agent is a named agent configuration that includes its own prompt and tool set. A sub-agent is a custom agent i" | +| dispatch.nested | false | https://awesome-copilot.github.com/learning-hub/agents-and-subagents/ | "By default, subagents do not keep spawning additional subagents." | +| dispatch.maxDepth | 1 | https://awesome-copilot.github.com/learning-hub/agents-and-subagents/ | "Depth counts how many agents are nested within one another. When the depth limit is reached, the innermost agent cannot" | +| dispatch.background | true | https://docs.github.com/en/copilot/how-tos/copilot-cli/speed-up-task-completion | "Allow Copilot to use subagents and work autonomously to implement the plan without any further input." | +| dispatch.subagentToolkit | full | https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/create-custom-agents-for-cli | "By default, custom agents have access to all tools. If you restrict an agent's access, a tools specification is added" | +| dispatch.backgroundDispatch | false | https://code.visualstudio.com/docs/copilot/agents/subagents | "By default, subagents cannot spawn further subagents. This prevents infinite recursion when agents accidentally call themselves in a loop." The setting `chat.subagents.allowInvocationsFromSubagents` | + +Sources consulted: +- https://github.com/github/copilot-cli/blob/main/README.md (via Context7 /github/copilot-cli) +- https://github.com/github/copilot-sdk/blob/main/docs/features/custom-agents.md (via Context7 /github/copilot-sdk) +- https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/add-mcp-servers +- https://docs.github.com/en/copilot/reference/hooks-reference +- https://docs.github.com/en/copilot/concepts/agents/copilot-cli/comparing-cli-features +- https://awesome-copilot.github.com/learning-hub/agents-and-subagents/ + +Documentation gaps: +- runtime — docs describe the CLI binary and the SDK (Node.js/Go/Python/Rust) but do not state what runtime the CLI host itself or its plugin/extension loader executes in. +- dispatch.nested exact authoritative source is awesome-copilot.github.com (community docs) not docs.github.com. + +--- + +## kilo + +| Axis | Value | Source | Evidence | +|---|---|---|---| +| embeddingMode | imperative | https://kilo.ai/docs/automate/extending/plugins | "Plugins extend Kilo by hooking into events and adding functionality. They can: add custom tools the model can call (like read, write, bash)" | +| commandSurface | slash-file | https://kilo.ai/docs/customize/workflows | "Workflows, also known as slash commands, allow users to automate repetitive tasks by defining step-by-step instructions" | +| modelMode | active | https://kilo.ai/docs/automate/extending/plugins | "provider — dynamically supply model catalogs. auth — register OAuth or API-key flows for model providers. chat.params — Mutate temperature" | +| hookBus | host | https://kilo.ai/docs/automate/extending/plugins | "event — fires for every internal bus event. Session: session.created, session.updated, session.idle, session.error, session.deleted" | +| stateIO | filesystem | https://kilo.ai/docs/contributing/architecture | "Local execution and hosted execution are separate boundaries. Local runtime instances are Directory-keyed runtime context" | +| transport | mcp | https://kilo.ai/docs/automate/mcp/what-is-mcp | "Kilo Code implements the Model Context Protocol to connect to both local and remote MCP servers" | +| runtime | bun | https://kilo.ai/docs/automate/extending/plugins | "npm plugins are installed automatically at startup using Bun. Plugin context includes $ (Bun shell). Plugins are TypeScript or JavaScript mo" | +| dispatch.namedDispatch | true | https://kilo.ai/docs/customize/custom-subagents | "Configured subagents can be invoked automatically by primary agents (like the Orchestrator) using the Task tool" | +| dispatch.nested | true | https://github.com/Kilo-Org/kilocode/issues/7055 | "A subagent can still call the task tool if its merged permissions contain an explicit task rule, which enables nested su" | +| dispatch.maxDepth | -1 | https://github.com/Kilo-Org/kilocode/issues/8637 | "there is no maximum nesting depth and the system relies entirely on permission gating" | +| dispatch.background | true | https://kilo.ai/docs/code-with-ai/agents/orchestrator-mode | "Agents are also capable of launching multiple subagent sessions concurrently to facilitate parallel processing." | +| dispatch.subagentToolkit | undocumented | no authoritative doc — searched: https://kilo.ai/docs/customize/custom-subagents | — | +| dispatch.backgroundDispatch | false | https://kilo.ai/docs/automate/tools/new-task | "Importantly, subagents cannot spawn further subagents; only primary agents can use the `new_task` tool." | + +Sources consulted: +- https://kilo.ai/docs/automate/extending/plugins +- https://kilo.ai/docs/customize/custom-subagents +- https://kilo.ai/docs/customize/workflows +- https://kilo.ai/docs/automate/mcp/what-is-mcp +- https://kilo.ai/docs/code-with-ai/agents/orchestrator-mode +- https://kilo.ai/docs/contributing/architecture +- https://github.com/Kilo-Org/kilocode/issues/7055 +- https://github.com/Kilo-Org/kilocode/issues/8637 +- /websites/kilo_ai (Context7) + +Documentation gaps: +- dispatch.subagentToolkit — docs describe per-subagent configurable permissions (allow/ask/deny) but do not document a single default toolkit level (full vs read-only) for subagents that lack explicit permission overrides. + +--- + +## windsurf + +| Axis | Value | Source | Evidence | +|---|---|---|---| +| embeddingMode | declarative | https://docs.devin.ai/desktop/cascade/cascade | "Cascade operates through configuration files rather than code plugins: .codeiumignore for file filtering, Memories and Rules for customizing" | +| commandSurface | slash-file | https://docs.devin.ai/desktop/cascade/workflows | "Workflows are authored as markdown files (.md extension) … triggered through slash commands using the format /[workflow-name]." | +| modelMode | passive | https://docs.devin.ai/desktop/models.md | "Models are selectable via configuration/UI only (SWE-1.5, SWE-1.6, Adaptive, Arena tiers, Claude, GPT)." | +| hookBus | host | https://docs.devin.ai/desktop/cascade/hooks.md | "Cascade supports twelve hook events covering critical workflow points … Pre-hooks (can block actions): pre_read_code, pre_write_code, pre_ru" | +| stateIO | filesystem | https://docs.devin.ai/desktop/cascade/cascade | "Cascade can create and modify codebases directly … File access can be restricted through .codeiumignore files" | +| transport | mcp | https://docs.devin.ai/desktop/cascade/mcp | "Cascade now natively integrates with MCP, allowing you to bring your own selection of MCP servers for Cascade to use." | +| runtime | undocumented | no authoritative doc — searched: https://docs.devin.ai/windsurf/plugins/getting-started.md, /llmstxt/windsurf_llms-full_txt (Context7) | — | +| dispatch.namedDispatch | undocumented | no authoritative doc — searched: https://docs.devin.ai/cli/subagents.md, https://docs.devin.ai/desktop/agent-command-center.md | — | +| dispatch.nested | undocumented | no authoritative doc — searched: https://docs.devin.ai/cli/subagents.md | — | +| dispatch.maxDepth | undocumented | no authoritative doc — searched: https://docs.devin.ai/cli/subagents.md | — | +| dispatch.background | undocumented | no authoritative doc — searched: https://docs.devin.ai/desktop/acp.md, https://docs.devin.ai/cli/subagents.md | — | +| dispatch.subagentToolkit | undocumented | no authoritative doc — searched: https://docs.devin.ai/cli/subagents.md | — | +| dispatch.backgroundDispatch | undocumented | no authoritative doc — https://docs.devin.ai/desktop/cascade/cascade and https://docs.devin.ai/desktop/devin-local (official Windsurf/Devin docs, via docs.windsurf.com redirects) | The Windsurf/Cascade docs describe a background planning agent only in these terms: "In the background, a specialized planning agent continuously refines the long-term plan while your selected model f | + +Sources consulted: +- https://docs.devin.ai/desktop/cascade/workflows +- https://docs.devin.ai/desktop/cascade/mcp +- https://docs.devin.ai/desktop/cascade/hooks.md +- https://docs.devin.ai/desktop/cascade/cascade +- https://docs.devin.ai/desktop/models.md +- https://docs.devin.ai/windsurf/plugins/getting-started.md +- https://docs.devin.ai/cli/subagents.md +- /llmstxt/windsurf_llms-full_txt (Context7) + +Documentation gaps: +- dispatch.namedDispatch — Cascade docs do not document a user-facing named sub-agent dispatch system. +- dispatch.nested — no documentation for nested sub-agent support in Windsurf Cascade. +- dispatch.maxDepth — no documented depth limit for Cascade sub-agents. +- dispatch.background — Cascade has an internal background planning agent but no documented user-facing background sub-agent dispatch. +- dispatch.subagentToolkit — no documentation for toolkit restrictions on Cascade sub-agents. +- runtime — Windsurf IDE is Electron-based but no programmatic plugin runtime is documented to developers. + +--- + +## trae + +| Axis | Value | Source | Evidence | +|---|---|---|---| +| embeddingMode | imperative | https://traeide.com/docs/how-to-manage-extensions-in-trae-ide | "Trae IDE is a VSCode fork; 'If an extension isn't available in Trae's store, you can install it from VS Code's marketplace' — inherits VSCode in-process extension model" | +| commandSurface | slash-file | https://docs.trae.ai/ide/skills | "Skills stored as SKILL.md files in '.trae/skills/{skill_name}/' directory; 'Trae allows you to manually trigger skills if needed'" | +| modelMode | passive | https://docs.trae.ai/ide/models | "Model selection via UI: 'click on the current model name to open the model list'; no programmatic model/LLM request API documented for plugins" | +| hookBus | engine | https://news.ycombinator.com/item?id=44703164 | "Trae is 'ByteDance's VSCode fork' built on Electron/Monaco; inherits VSCode extension host lifecycle (activate/deactivate hooks, event subsc" | +| stateIO | filesystem | https://traeide.com/news/6 | "Rules at '.trae/project_rules.md', skills at '.trae/skills/', MCP config at '.trae/mcp.json'; 'codebase files always remain on your local de" | +| transport | mcp | https://docs.trae.ai/ide/model-context-protocol | "Page title from official docs: 'In TRAE IDE, MCP servers support three transport types' — MCP is built-in" | +| runtime | node | https://news.ycombinator.com/item?id=44703164 | "Trae is a VSCode fork built on Electron; 'Electron is designed to create desktop applications… a backend using the Node.js runtime'" | +| dispatch.namedDispatch | true | https://docs.trae.ai/ide/agent | "Agents in Trae 'can be called individually, or automatically called by SOLO Agent at the corresponding stage'" | +| dispatch.nested | undocumented | no authoritative doc — searched: https://docs.trae.ai/ide/solo-mode, https://docs.trae.ai/ide/agent | — | +| dispatch.maxDepth | undocumented | no authoritative doc — searched: https://docs.trae.ai/ide/solo-mode | — | +| dispatch.background | true | https://news.aibase.com/news/22829 | "SOLO 'supports multi-tasking, allowing you to work on multiple development tasks simultaneously'; 'run multiple agents i" | +| dispatch.subagentToolkit | undocumented | no authoritative doc — searched: https://docs.trae.ai/ide/agent | — | +| dispatch.backgroundDispatch | undocumented | no authoritative doc — https://docs.trae.ai/ide/agent; https://github.com/bytedance/trae-agent/blob/main/docs/roadmap.md | Trae's official documentation (docs.trae.ai) and the trae-agent GitHub roadmap do not document background/async agent dispatch or whether a background-spawned agent can itself spawn further sub-agents | + +Sources consulted: +- https://docs.trae.ai/ide/model-context-protocol +- https://docs.trae.ai/ide/agent +- https://docs.trae.ai/ide/skills +- https://docs.trae.ai/ide/solo-mode +- https://docs.trae.ai/ide/solo-coder +- https://traeide.com/news/6 +- https://traeide.com/docs/how-to-manage-extensions-in-trae-ide +- https://news.ycombinator.com/item?id=44703164 +- https://news.aibase.com/news/22829 + +Documentation gaps: +- dispatch.nested — docs describe two-tier orchestration (SOLO → named agents) but do not state whether a spawned sub-agent can itself spawn further sub-agents. +- dispatch.maxDepth — no integer depth limit documented beyond one orchestrator level. +- dispatch.subagentToolkit — docs say agents can be configured with 'callable MCP services and other capabilities' but do not state whether sub-agents receive a full vs. restricted tool set. + +--- + +## kimi + +| Axis | Value | Source | Evidence | +|---|---|---|---| +| embeddingMode | imperative | https://context7.com/moonshotai/kimi-cli/llms.txt | "from kimi_cli.app import KimiCLI, enable_logging ... instance = await KimiCLI.create(session, agent_file=myagent) ... class Ls(CallableTool2)" | +| commandSurface | slash-file | https://github.com/moonshotai/kimi-cli/blob/main/docs/en/customization/skills.md | "/skill:code-style ... /flow:code-review — Skills are SKILL.md markdown files with YAML frontmatter that become /skill: and /flow:" | +| modelMode | passive | https://github.com/moonshotai/kimi-cli/blob/main/docs/en/configuration/providers.md | "Use the `/model` command to switch between available models and thinking modes ... `--model` option overrides the default model" | +| hookBus | host | https://moonshotai.github.io/kimi-cli/en/customization/hooks.html | "Core: Add hooks system (Beta) — configure `[[hooks]]` in `config.toml` to run custom shell commands at 13 lifecycle events including `PreToo" | +| stateIO | filesystem | https://github.com/MoonshotAI/kimi-cli | "Kimi Code CLI is an AI agent that runs in the terminal ... capable of reading and editing code, executing shell commands, searching files" | +| transport | mcp | https://github.com/moonshotai/kimi-cli/blob/main/docs/en/reference/kimi-mcp.md | "kimi mcp add ... --transport stdio|http ... Manage MCP Servers: Use the kimi mcp sub-command group to add, list, remove, or authorize MCP se" | +| runtime | python | https://context7.com/moonshotai/kimi-cli/llms.txt | "from kimi_cli.app import KimiCLI ... from kosong.tooling import CallableTool2 — CLI core is Python" | +| dispatch.namedDispatch | true | https://moonshotai.github.io/kimi-cli/en/customization/agents.html | "subagents:\n coder:\n path: ./coder-sub.yaml\n description: \"Handle coding tasks\"\n reviewer:\n path: ./reviewer-sub.yaml" | +| dispatch.nested | false | https://moonshotai.github.io/kimi-cli/en/customization/agents.html | "All subagent types are prohibited from nesting the `Agent` tool (subagents cannot create their own subagents). Only root" | +| dispatch.maxDepth | 1 | https://moonshotai.github.io/kimi-cli/en/customization/agents.html | "All subagent types are prohibited from nesting the `Agent` tool (subagents cannot create their own subagents). Only root" | +| dispatch.background | true | https://moonshotai.github.io/kimi-cli/en/customization/agents.html | "Subagents support foreground and background modes. The `run_in_background` parameter allows tasks to execute asynchronou" | +| dispatch.subagentToolkit | undocumented | no authoritative doc — searched: https://moonshotai.github.io/kimi-cli/en/customization/agents.html | — | +| dispatch.backgroundDispatch | false | https://github.com/moonshotai/kimi-cli/blob/main/docs/en/customization/agents.md (also mirrored at https://moonshotai.github.io/kimi-cli/en/customization/agents.html) | "All subagent types are prohibited from nesting the `Agent` tool, meaning subagents cannot create their own subagents. Only the root agent has access to the `Agent` tool for launching further subagent | + +Sources consulted: +- https://moonshotai.github.io/kimi-cli/en/customization/hooks.html +- https://moonshotai.github.io/kimi-cli/en/customization/agents.html +- https://github.com/MoonshotAI/kimi-cli +- https://github.com/moonshotai/kimi-cli/blob/main/docs/en/customization/skills.md +- https://github.com/moonshotai/kimi-cli/blob/main/docs/en/customization/agents.md +- https://github.com/moonshotai/kimi-cli/blob/main/docs/en/reference/kimi-mcp.md +- https://context7.com/moonshotai/kimi-cli/llms.txt +- /moonshotai/kimi-cli (Context7) + +Documentation gaps: +- dispatch.subagentToolkit — docs show three built-in subagent types each with different tool subsets (coder=full, explore=read-only, plan=no shell/write); no single 'full' or 'read-only' value covers all types; maintainer should clarify the intended classification. +- runtime — CLI core is Python; a Rust Wire implementation also exists; docs do not state a canonical plugin extension runtime. diff --git a/eslint-rules/lib/platform-guard.cjs b/eslint-rules/lib/platform-guard.cjs new file mode 100644 index 000000000..d3009de27 --- /dev/null +++ b/eslint-rules/lib/platform-guard.cjs @@ -0,0 +1,627 @@ +'use strict'; + +/** + * platform-guard.cjs — precision backbone for no-path-literal-in-assert. + * + * Exported API: + * classifyPlatformTest(node) → 'windows' | 'not-windows' | null + * isWindowsExcludedNode(node, sourceCode) → boolean + * + * Shapes handled by isWindowsExcludedNode: + * + * (A) Consequent of `if () { ... }`: + * if (process.platform !== 'win32') { } + * + * (B) Alternate of `if () { ... } else { }`: + * if (process.platform === 'win32') { ... } else { } + * + * (C) A preceding sibling IfStatement that is a Windows early-return guard, + * making the node unreachable on Windows: + * if (process.platform === 'win32') return; + * if (process.platform === 'win32') return t.skip(...); + * if (process.platform === 'win32') { ...; return; } + * + * (D) Hoisted windows-boolean consumed by (A)/(B)/(C). Both the conventional + * names (isWindows, IS_WINDOWS, isWin, onWindows) AND arbitrary-named + * variables (e.g. `const winFlag = process.platform === 'win32'`) are + * resolved by looking up the variable's initializer in the enclosing scope + * and classifying that expression. Negation (`!winFlag`) is applied after + * the lookup, so `if (!winFlag)` is correctly recognized as a not-windows + * guard when winFlag was initialized to a windows test. + * + * If a shape is genuinely ambiguous, the function returns false so the rule + * errs toward reporting — the fix is to teach this helper, never an opt-out. + */ + +/** + * Identifier names conventionally used for "is this Windows?" booleans. + * @type {Set} + */ +const WINDOWS_BOOL_NAMES = new Set(['isWindows', 'IS_WINDOWS', 'isWin', 'onWindows']); + +/** + * Classify a test expression as a Windows test, not-Windows test, or unrelated. + * + * Recognized forms: + * - `process.platform === 'win32'` → 'windows' + * - `process.platform !== 'win32'` → 'not-windows' + * - `os.platform() === 'win32'` → 'windows' + * - `os.platform() !== 'win32'` → 'not-windows' + * - `isWindows` / `IS_WINDOWS` / etc → 'windows' + * - `!isWindows` / etc → 'not-windows' + * + * @param {import('eslint').Rule.Node} node + * @returns {'windows' | 'not-windows' | null} + */ +function classifyPlatformTest(node) { + if (!node) return null; + + // Binary: X === 'win32' or X !== 'win32' or 'win32' === X etc. + if (node.type === 'BinaryExpression' && (node.operator === '===' || node.operator === '!==')) { + const { left, right, operator } = node; + if (_isPlatformExpr(left) && _isWin32Literal(right)) { + return operator === '===' ? 'windows' : 'not-windows'; + } + if (_isPlatformExpr(right) && _isWin32Literal(left)) { + return operator === '===' ? 'windows' : 'not-windows'; + } + } + + // Bare identifier: isWindows, IS_WINDOWS, isWin, onWindows + if (node.type === 'Identifier' && WINDOWS_BOOL_NAMES.has(node.name)) { + return 'windows'; + } + + // Negated: !isWindows + if ( + node.type === 'UnaryExpression' && + node.operator === '!' && + node.argument.type === 'Identifier' && + WINDOWS_BOOL_NAMES.has(node.argument.name) + ) { + return 'not-windows'; + } + + return null; +} + +/** True if node is `process.platform` or `os.platform()` */ +function _isPlatformExpr(node) { + // process.platform + if ( + node.type === 'MemberExpression' && + !node.computed && + node.object.type === 'Identifier' && + node.object.name === 'process' && + node.property.type === 'Identifier' && + node.property.name === 'platform' + ) { + return true; + } + // os.platform() + if ( + node.type === 'CallExpression' && + node.callee.type === 'MemberExpression' && + !node.callee.computed && + node.callee.object.type === 'Identifier' && + node.callee.object.name === 'os' && + node.callee.property.type === 'Identifier' && + node.callee.property.name === 'platform' + ) { + return true; + } + return false; +} + +/** True if node is the string literal 'win32' */ +function _isWin32Literal(node) { + return node.type === 'Literal' && node.value === 'win32'; +} + +/** + * Returns true when `targetNode` only executes on non-Windows because it is + * control-dependent on one of the recognized Windows-guard shapes. + * + * @param {import('eslint').Rule.Node} targetNode + * @param {import('eslint').SourceCode} sourceCode + * @returns {boolean} + */ +function isWindowsExcludedNode(targetNode, sourceCode) { + // Walk ancestors bottom-up to find a guarding IfStatement. + const ancestors = _getAncestors(targetNode, sourceCode); + + for (let i = ancestors.length - 1; i >= 0; i--) { + const ancestor = ancestors[i]; + + if (ancestor.type !== 'IfStatement') continue; + + const testClassification = _classifyPlatformTestWithHoisting( + ancestor.test, + targetNode, + sourceCode + ); + + if (!testClassification) continue; + + // Determine which branch targetNode is in + const inConsequent = _containsNode(ancestor.consequent, targetNode); + const inAlternate = ancestor.alternate != null && _containsNode(ancestor.alternate, targetNode); + + if (inConsequent && testClassification === 'not-windows') { + // if (platform !== 'win32') { } → excluded + return true; + } + if (inAlternate && testClassification === 'windows') { + // if (platform === 'win32') { … } else { } → excluded + return true; + } + } + + // Check for early-return guards in the same block as the target node + if (_hasEarlyWindowsReturnBefore(targetNode, sourceCode)) return true; + + return false; +} + +/** + * Classify a test expression, resolving hoisted windows-boolean variables. + * + * C3 (binding-aware): for bare Identifier or !Identifier test forms, this + * function resolves the variable's binding in the lexical scope: + * + * 1. If `sourceCode.getScope` is available (real ESLint rule context), use + * it to resolve the NEAREST binding of the identifier, walking scope.upper + * so inner shadows take priority. If a binding is found in-file: + * a. Classify the initializer — not a platform test → return null. + * b. Check for reassignment (any write reference after init) → return null. + * c. Otherwise return the init classification (with negation applied). + * If NO in-file binding exists (global/import), fall through to the name + * heuristic below. + * + * 2. AST-walk fallback (unit-test contexts without live scope): for identifiers + * NOT in WINDOWS_BOOL_NAMES, use _resolveIdentifierInitBindingAware which + * respects inner shadows and reassignment. For names IN WINDOWS_BOOL_NAMES + * with no in-file binding found, apply the name heuristic. + * + * 3. Direct platform expressions (`process.platform === 'win32'`, etc.) are + * classified directly (no change from before). + * + * @param {import('eslint').Rule.Node} testNode — the IfStatement's .test + * @param {import('eslint').Rule.Node} targetNode — the node we are checking + * @param {import('eslint').SourceCode} sourceCode + * @returns {'windows' | 'not-windows' | null} + */ +function _classifyPlatformTestWithHoisting(testNode, targetNode, sourceCode) { + // Step 1: try direct classification of platform expressions + // (BinaryExpression process.platform === 'win32', etc.) + // Do NOT use classifyPlatformTest here for the bare-identifier forms — + // we want binding-aware resolution for those. + const directBinary = _classifyPlatformExprOnly(testNode); + if (directBinary) return directBinary; + + // Extract the identifier and negation flag from the test expression. + let identNode = null; + let negated = false; + + if (testNode.type === 'Identifier') { + identNode = testNode; + negated = false; + } else if ( + testNode.type === 'UnaryExpression' && + testNode.operator === '!' && + testNode.argument.type === 'Identifier' + ) { + identNode = testNode.argument; + negated = true; + } + + if (!identNode) return null; + + const identName = identNode.name; + + // Step 2: binding-aware resolution via ESLint scope (when available). + if (typeof sourceCode.getScope === 'function') { + const scopeResult = _resolveIdentifierViaScope(identNode, identName, negated, sourceCode); + // scopeResult is one of: + // 'windows' | 'not-windows' — binding found, init classifies as platform test + // null — binding found but doesn't classify (or reassigned) + // 'no-binding' — no in-file binding; fall through to name heuristic + if (scopeResult !== 'no-binding') return scopeResult; + // Fall through: no in-file binding → name heuristic below. + } else { + // AST-walk fallback (unit-test contexts without live scope). + // Use binding-aware AST walk for ALL names. + const astResult = _resolveIdentifierInitBindingAware(identName, identNode, targetNode, sourceCode); + if (astResult !== 'no-binding') { + if (!astResult) return null; + return negated + ? (astResult === 'windows' ? 'not-windows' : 'windows') + : astResult; + } + // No binding found via AST walk → fall through to name heuristic. + } + + // Step 3: name heuristic — only for globally-recognized Windows bool names + // that have no in-file binding (imported/global constants like `isWindows` + // imported from a test helper). + if (WINDOWS_BOOL_NAMES.has(identName)) { + return negated ? 'not-windows' : 'windows'; + } + + return null; +} + +/** + * Classify a BinaryExpression or os.platform() call as a platform test. + * Does NOT handle bare Identifiers or !Identifier — those need binding-aware + * resolution (handled above in _classifyPlatformTestWithHoisting). + * + * @param {import('eslint').Rule.Node} node + * @returns {'windows' | 'not-windows' | null} + */ +function _classifyPlatformExprOnly(node) { + if (!node) return null; + if (node.type === 'BinaryExpression' && (node.operator === '===' || node.operator === '!==')) { + const { left, right, operator } = node; + if (_isPlatformExpr(left) && _isWin32Literal(right)) { + return operator === '===' ? 'windows' : 'not-windows'; + } + if (_isPlatformExpr(right) && _isWin32Literal(left)) { + return operator === '===' ? 'windows' : 'not-windows'; + } + } + return null; +} + +/** + * Resolve an identifier's binding via ESLint scope analysis. + * + * Walks `scope.upper` from the identifier's immediate scope to find the NEAREST + * binding (so inner shadows take priority over outer declarations). + * + * @param {import('eslint').Rule.Node} identNode + * @param {string} identName + * @param {boolean} negated + * @param {import('eslint').SourceCode} sourceCode + * @returns {'windows' | 'not-windows' | null | 'no-binding'} + */ +function _resolveIdentifierViaScope(identNode, identName, negated, sourceCode) { + let scope; + try { + scope = sourceCode.getScope(identNode); + } catch (_) { + return 'no-binding'; + } + if (!scope) return 'no-binding'; + + // Walk scope chain from innermost to outermost; take the NEAREST binding. + let s = scope; + while (s) { + const variable = s.variables.find(v => v.name === identName); + if (variable) { + // Found an in-file binding (the NEAREST one wins — inner shadow beats outer). + const defs = variable.defs; + if (!defs || defs.length === 0) { + // Binding exists but no declarator (e.g. function parameter) — no init. + return null; + } + const decl = defs[0].node; // VariableDeclarator + if (!decl || !decl.init) { + // No initializer (e.g. `let w;`) → not a platform test. + return null; + } + // Classify the initializer. + const cls = classifyPlatformTest(decl.init); + if (!cls) return null; // init is not a platform test + + // Check for reassignment: any write reference that is NOT the initialization. + const isReassigned = variable.references.some( + ref => ref.isWrite() && !ref.init + ); + if (isReassigned) return null; + + // Valid platform guard binding found. + return negated + ? (cls === 'windows' ? 'not-windows' : 'windows') + : cls; + } + s = s.upper; + } + + // No binding found in any scope — treat as a global/imported name. + return 'no-binding'; +} + +/** + * Binding-aware AST-walk resolver — used as a fallback when + * sourceCode.getScope is not available. + * + * Walks ancestor blocks from innermost to outermost, looking for a + * VariableDeclaration of `name` that precedes `targetNode`. + * + * Key differences from the old _resolveIdentifierInit: + * - Returns 'no-binding' when NO declaration of `name` is found in any + * ancestor block (so the caller can apply the name heuristic). + * - Returns null (not 'no-binding') when a declaration IS found but: + * • its init does not classify as a platform test, OR + * • the variable is reassigned (any ExpressionStatement `name = ...` + * appears before targetNode after the declaration), OR + * • an inner-scope declaration shadows the outer one (inner wins). + * - Stops at the FIRST block that declares `name` (innermost shadow wins). + * + * @param {string} name + * @param {import('eslint').Rule.Node} identNode — the Identifier AST node (for inner-shadow check) + * @param {import('eslint').Rule.Node} targetNode — the assert CallExpression node + * @param {import('eslint').SourceCode} sourceCode + * @returns {'windows' | 'not-windows' | null | 'no-binding'} + */ +function _resolveIdentifierInitBindingAware(name, identNode, targetNode, sourceCode) { + const ancestors = _getAncestors(targetNode, sourceCode); + + for (let i = ancestors.length - 1; i >= 0; i--) { + const block = ancestors[i]; + if (block.type !== 'BlockStatement' && block.type !== 'Program') continue; + + const stmts = block.body; + if (!stmts) continue; + + // Find which direct-child statement contains the targetNode. + let targetIdx = -1; + for (let j = 0; j < stmts.length; j++) { + if (_containsNode(stmts[j], targetNode) || stmts[j] === targetNode) { + targetIdx = j; + break; + } + } + if (targetIdx === -1) continue; + + // Scan all preceding siblings in this block for a declaration of `name`. + let foundDecl = null; + let foundDeclIdx = -1; + for (let j = 0; j < targetIdx; j++) { + const stmt = stmts[j]; + if (stmt.type !== 'VariableDeclaration') continue; + for (const decl of stmt.declarations) { + if ( + decl.type === 'VariableDeclarator' && + decl.id && + decl.id.type === 'Identifier' && + decl.id.name === name + ) { + foundDecl = decl; + foundDeclIdx = j; + break; + } + } + if (foundDecl) break; + } + + if (foundDecl) { + // A binding was found in this block. Innermost shadow wins — stop climbing. + + // No initializer → not a platform guard. + if (!foundDecl.init) return null; + + // Init must classify as a platform test. + const cls = classifyPlatformTest(foundDecl.init); + if (!cls) return null; + + // Check for reassignment: any ExpressionStatement `name = ...` between + // foundDeclIdx and targetIdx. + if (_hasReassignmentBetween(name, stmts, foundDeclIdx + 1, targetIdx)) { + return null; + } + + return cls; + } + + // No declaration found in this block — continue climbing to outer scope. + } + + // No declaration found in any ancestor block. + return 'no-binding'; +} + +/** + * Returns true when any statement in stmts[fromIdx..toIdx) is an assignment + * expression ` = ...` (simple reassignment, not an initializer). + * + * @param {string} name + * @param {Array} stmts + * @param {number} fromIdx — inclusive + * @param {number} toIdx — exclusive + * @returns {boolean} + */ +function _hasReassignmentBetween(name, stmts, fromIdx, toIdx) { + for (let j = fromIdx; j < toIdx; j++) { + const stmt = stmts[j]; + if ( + stmt.type === 'ExpressionStatement' && + stmt.expression.type === 'AssignmentExpression' && + stmt.expression.left.type === 'Identifier' && + stmt.expression.left.name === name + ) { + return true; + } + } + return false; +} + +/** + * Legacy alias kept for _isWindowsEarlyReturn's call to + * _classifyPlatformTestWithHoisting, which uses stmt (the IfStatement) as + * the "targetNode" to look up hoisting context. No callers outside that path. + * + * @param {string} name + * @param {import('eslint').Rule.Node} targetNode + * @param {import('eslint').SourceCode} sourceCode + * @returns {'windows' | 'not-windows' | null} + */ +function _resolveIdentifierInit(name, targetNode, sourceCode) { + const result = _resolveIdentifierInitBindingAware(name, null, targetNode, sourceCode); + if (result === 'no-binding') return null; + return result; +} + +/** + * True when there is a preceding sibling statement (before targetNode in ANY + * enclosing block — function body, nested block, or Program) that is an + * IfStatement whose consequence is a Windows-only early return — making + * targetNode unreachable on Windows. + * + * Recognized patterns: + * if (windowsTest) return; + * if (windowsTest) return ; + * if (windowsTest) { …; return; } — block with a return + * + * C2 fix: climbs ALL ancestor blocks, not just the innermost one. + * An early-return guard in a function body before a nested if-block that + * contains targetNode is equally valid (control cannot reach targetNode on Windows + * because the outer return fired first). + */ +function _hasEarlyWindowsReturnBefore(targetNode, sourceCode) { + const ancestors = _getAncestors(targetNode, sourceCode); + + // Walk ALL ancestor blocks bottom-up (innermost first). + for (let i = ancestors.length - 1; i >= 0; i--) { + const block = ancestors[i]; + if (block.type !== 'BlockStatement' && block.type !== 'Program') continue; + + const stmts = block.body; + if (!stmts) continue; + + // Find targetNode's position in this block's statements. + // targetNode might be nested inside a statement; we need the direct-child index. + let targetStmtIdx = -1; + for (let j = 0; j < stmts.length; j++) { + if (_containsNode(stmts[j], targetNode) || stmts[j] === targetNode) { + targetStmtIdx = j; + break; + } + } + if (targetStmtIdx === -1) continue; + + // Scan preceding siblings in this block for a Windows early-return guard. + for (let j = 0; j < targetStmtIdx; j++) { + const stmt = stmts[j]; + if (_isWindowsEarlyReturn(stmt, sourceCode, block)) return true; + } + + // No guard found in this block — continue climbing to outer blocks. + // (Unlike the IfStatement-branch check, an early-return in an outer block + // before the nested block that contains targetNode is equally protective.) + } + + return false; +} + +/** + * True when `stmt` is `if () return;` / `if () return ;` + * / `if () { …; return; }` with no `else`. + */ +function _isWindowsEarlyReturn(stmt, sourceCode, _block) { + if (stmt.type !== 'IfStatement') return false; + if (stmt.alternate != null) return false; // has else → not a simple guard + + const testClass = _classifyPlatformTestWithHoisting(stmt.test, stmt, sourceCode); + if (testClass !== 'windows') return false; + + // Consequent must contain a return statement + const consequent = stmt.consequent; + if (!consequent) return false; + + if (consequent.type === 'ReturnStatement') return true; + + if (consequent.type === 'BlockStatement') { + // Only direct-child ReturnStatements are checked. Nested/conditional returns + // (e.g. inside inner if-blocks) are intentionally NOT treated as guards — + // this is the sound conservative choice: we only suppress the report when + // we are certain execution cannot continue on Windows. + return consequent.body.some(s => s.type === 'ReturnStatement'); + } + + return false; +} + +/** + * Get the ancestor chain for `node` using the sourceCode API. + * Returns an array from outermost to innermost (not including node itself). + */ +function _getAncestors(node, sourceCode) { + // ESLint 8+: sourceCode.getAncestors(node) + if (sourceCode.getAncestors) { + try { + return sourceCode.getAncestors(node); + } catch (_) { + // Fallback: not always available outside a rule handler + } + } + // Fallback: traverse the AST manually (used in unit tests) + return _findAncestors(sourceCode.ast, node); +} + +/** + * Find the ancestor chain by walking the AST. + * Returns array from root to immediate parent of target. + * Skips `parent` and other cycle-inducing keys. + */ +function _findAncestors(root, target) { + const chain = []; + function walk(node, ancestors) { + if (!node || typeof node !== 'object') return false; + if (node === target) { + chain.push(...ancestors); + return true; + } + for (const key of Object.keys(node)) { + if (SKIP_KEYS.has(key)) continue; + const child = node[key]; + if (Array.isArray(child)) { + for (const item of child) { + if (item && typeof item === 'object' && item.type) { + if (walk(item, [...ancestors, node])) return true; + } + } + } else if (child && typeof child === 'object' && child.type) { + if (walk(child, [...ancestors, node])) return true; + } + } + return false; + } + walk(root, []); + return chain; +} + +/** + * Keys to skip when traversing an AST node to avoid circular parent refs. + * ESLint attaches `parent` to every node, which creates cycles. + */ +const SKIP_KEYS = new Set(['parent', 'tokens', 'comments']); + +/** + * Returns true when `container` node contains `target` node (by identity). + * Skips `parent` and other non-AST keys to avoid circular reference loops. + */ +function _containsNode(container, target) { + if (!container || typeof container !== 'object') return false; + if (container === target) return true; + for (const key of Object.keys(container)) { + if (SKIP_KEYS.has(key)) continue; + const child = container[key]; + if (Array.isArray(child)) { + for (const item of child) { + if (item && typeof item === 'object' && item.type) { + if (_containsNode(item, target)) return true; + } + } + } else if (child && typeof child === 'object' && child.type) { + if (_containsNode(child, target)) return true; + } + } + return false; +} + +module.exports = { + classifyPlatformTest, + isWindowsExcludedNode, +}; diff --git a/eslint-rules/lib/portability-vocab.cjs b/eslint-rules/lib/portability-vocab.cjs new file mode 100644 index 000000000..017068e37 --- /dev/null +++ b/eslint-rules/lib/portability-vocab.cjs @@ -0,0 +1,337 @@ +'use strict'; + +/** + * portability-vocab.cjs — single source of truth for path-related portability. + * + * PATH_RETURNING_FNS: canonical list of function calls (Node builtins and + * project resolvers) that return a filesystem path. The drift-guard test + * (tests/portability-vocab-drift.test.cjs) enforces completeness against + * src/runtime-homes.cts's exported path-returning functions. + * + * EXTEND THIS LIST when adding a new path resolver to the codebase. + * The drift-guard test will fail if you forget. + * + * ── Known boundaries ───────────────────────────────────────────────────────── + * + * Matching is by spelling: `path`, `os`, and the project resolver names below + * are assumed to refer to the standard Node modules / project resolver exports. + * A local variable that shadows one of these names (e.g. `const path = …`) is + * out of scope — the helpers treat it as the real module. + * + * isPosixNormalizerCall inspects only the DIRECT argument of the call node; + * deeper nesting (e.g. `String(path.join(...)).toLowerCase().replace(/\\/g,'/')`) + * is not covered — only the outermost call and one level of String() cast are + * visible to the rule. + */ + +/** + * Canonical set of function names (dotted or bare) that return filesystem paths. + * + * Format: + * - "path.join" → MemberExpression: object=Identifier{path}, property=Identifier{join} + * - "os.homedir" → MemberExpression: object=Identifier{os}, property=Identifier{homedir} + * - "getGlobalDir" → Identifier callee with that name + */ +const PATH_RETURNING_FNS = [ + // ── Node built-ins ────────────────────────────────────────────────────────── + 'path.join', + 'path.resolve', + 'path.dirname', + 'path.basename', + 'path.normalize', + 'path.relative', + 'os.homedir', + 'os.tmpdir', + + // ── Project resolvers (src/runtime-homes.cts exports + install.js helpers) ── + // Add bare function names here; dotted forms (e.g. obj.resolveX) are not used + // in the test corpus because these are module-level exports, not methods. + 'resolveAgentDir', + 'getGlobalConfigDir', + 'getGlobalSkillsBase', + 'getGlobalSkillDir', + 'getGlobalSkillDisplayPath', + 'resolveSkillsBaseFromDescriptor', + 'resolveConfigHomeFromDescriptor', + 'resolveKimiGlobalDir', + 'resolveAntigravityGlobalDir', + 'getGlobalDir', + 'getConfigDirFromHome', + 'resolveKiloConfigPath', + 'resolveOpencodeConfigPath', + 'computePathPrefix', + 'expandHome', + 'getPathX', + 'normalizeInstallRelativePath', + 'toPosixPath', +]; + +/** + * Returns true when `node` is a CallExpression whose callee matches one of the + * PATH_RETURNING_FNS entries. + * + * Handles two call shapes: + * - Dotted: path.join(…) → callee is MemberExpression{object: Identifier, property: Identifier} + * - Bare: getGlobalDir() → callee is Identifier + * + * @param {import('eslint').Rule.Node} node - AST node to inspect + * @returns {boolean} + */ +function isPathReturningCall(node) { + if (!node || node.type !== 'CallExpression') return false; + const callee = node.callee; + + // Dotted call: path.join, os.homedir, etc. + if ( + callee.type === 'MemberExpression' && + !callee.computed && + callee.object.type === 'Identifier' && + callee.property.type === 'Identifier' + ) { + const dotted = `${callee.object.name}.${callee.property.name}`; + if (PATH_RETURNING_FNS.includes(dotted)) return true; + } + + // Bare call: getGlobalConfigDir(), resolveKimiGlobalDir(), etc. + if (callee.type === 'Identifier') { + if (PATH_RETURNING_FNS.includes(callee.name)) return true; + } + + return false; +} + +/** + * Returns true when `node` is a string literal (or a template literal with no + * expressions) whose value contains '/' and does NOT look like a URL. + * + * URL exclusion: value starts with 'http://' or 'https://'. + * + * @param {import('eslint').Rule.Node} node + * @returns {boolean} + */ +function isPosixSlashStringLiteral(node) { + if (!node) return false; + + // Plain string literal + if (node.type === 'Literal' && typeof node.value === 'string') { + const v = node.value; + if (!v.includes('/')) return false; + if (v.startsWith('http://') || v.startsWith('https://')) return false; + return true; + } + + // Template literal with no expressions (static): `some/path` + if (node.type === 'TemplateLiteral' && node.expressions.length === 0) { + const v = node.quasis[0]?.value?.cooked ?? ''; + if (!v.includes('/')) return false; + if (v.startsWith('http://') || v.startsWith('https://')) return false; + return true; + } + + return false; +} + +/** + * Returns true when `node` is a CallExpression that normalizes its first + * argument to POSIX-style slashes. + * + * Recognized shapes: + * 1. .replace(/\\/g, '/') — regex /\\/g with replacement '/' + * 2. .replace(/[\\/]/g, '/') — regex /[\\/]/g with replacement '/' + * 3. .replaceAll('\\', '/') — literal backslash to slash + * 4. .replaceAll(path.sep, '/') — path.sep to slash + * 5. .split(path.sep).join('/') — split-join idiom + * 6. toPosixPath() — explicit wrapper + * + * Note: for replace(), we REQUIRE the 'g' flag on the regex AND the regex + * source must actually target backslashes (source `\\` or `[\\/]`). + * A regex like /foo/g or /\//g does NOT qualify. + * + * @param {import('eslint').Rule.Node} node + * @returns {boolean} + */ +function isPosixNormalizerCall(node) { + if (!node || node.type !== 'CallExpression') return false; + const callee = node.callee; + + // toPosixPath() + if (callee.type === 'Identifier' && callee.name === 'toPosixPath') return true; + + if ( + callee.type === 'MemberExpression' && + !callee.computed && + callee.property.type === 'Identifier' + ) { + const method = callee.property.name; + const args = node.arguments; + + // .replace(regex, '/') + // REQUIRE: g flag + regex source must target backslashes: `\\` or `[\\/]` + if (method === 'replace' && args.length >= 2) { + const regexArg = args[0]; + const replacementArg = args[1]; + if ( + regexArg.type === 'Literal' && + regexArg.regex != null && + regexArg.regex.flags.includes('g') && + _isBackslashTargetingRegex(regexArg.regex.pattern) && + _isSlashReplacement(replacementArg) + ) { + return true; + } + } + + // .replaceAll(sep, '/') + if (method === 'replaceAll' && args.length >= 2) { + const sepArg = args[0]; + const replacementArg = args[1]; + if (_isSlashReplacement(replacementArg)) { + // replaceAll('\\', '/') or replaceAll('\\\\', '/') or replaceAll(path.sep, '/') + if (_isBackslashLiteral(sepArg)) return true; + if (_isPathSep(sepArg)) return true; + } + } + + // .split(path.sep).join('/') + // The callee is .join — check the object for .split(path.sep) + if (method === 'join' && args.length >= 1 && _isSlashReplacement(args[0])) { + const splitCall = callee.object; + if ( + splitCall.type === 'CallExpression' && + splitCall.callee.type === 'MemberExpression' && + !splitCall.callee.computed && + splitCall.callee.property.type === 'Identifier' && + splitCall.callee.property.name === 'split' && + splitCall.arguments.length >= 1 && + _isPathSep(splitCall.arguments[0]) + ) { + return true; + } + } + } + + return false; +} + +/** True if node is the replacement '/' string literal */ +function _isSlashReplacement(node) { + return node && node.type === 'Literal' && node.value === '/'; +} + +/** + * True if regexPattern (the raw regex source string, as stored in the AST's + * `.regex.pattern` field) actually targets backslashes. + * + * Accepted: exactly `\\` (two-char, two backslashes: matches one backslash) + * exactly `[\\/]` (five-char: backslash-or-forward-slash charset) + * Rejected: `foo`, `\/` (forward-slash only), anything else. + * + * @param {string} pattern — the AST `.regex.pattern` string + * @returns {boolean} + */ +function _isBackslashTargetingRegex(pattern) { + // Pattern `\\` (two backslash chars in the regex) — matches a single backslash + if (pattern === '\\\\') return true; + // Pattern `[\\/]` (backslash-or-forward-slash charset) — five chars + if (pattern === '[\\\\/]') return true; + return false; +} + +/** True if node is a backslash literal ('\\' or '\\\\') */ +function _isBackslashLiteral(node) { + if (!node || node.type !== 'Literal') return false; + return node.value === '\\' || node.value === '\\\\'; +} + +/** True if node is path.sep */ +function _isPathSep(node) { + return ( + node && + node.type === 'MemberExpression' && + !node.computed && + node.object.type === 'Identifier' && + node.object.name === 'path' && + node.property.type === 'Identifier' && + node.property.name === 'sep' + ); +} + +/** + * If `node` is `String()`, return ``; otherwise return `node` as-is. + * Allows the rule to see through String() casts on path expressions. + * + * @param {import('eslint').Rule.Node} node + * @returns {import('eslint').Rule.Node} + */ +function unwrapString(node) { + if ( + node && + node.type === 'CallExpression' && + node.callee.type === 'Identifier' && + node.callee.name === 'String' && + node.arguments.length === 1 + ) { + return node.arguments[0]; + } + return node; +} + +/** + * If `node` is a method-call chain of the form `.replace(...)`, + * `.replaceAll(...)`, or `.split(...).join(...)` that is + * NOT a valid POSIX normalizer (i.e. `isPosixNormalizerCall(node)` is false), + * return the receiver (the `.object` of the callee MemberExpression). + * + * This lets the rule detect: + * `path.join(a,b).replace(/foo/g, '/')` → not a normalizer, but the + * receiver `path.join(a,b)` IS a path-returning call → violation. + * + * Only peels ONE layer. The caller is responsible for checking the peeled node. + * Returns `null` when `node` is already a valid normalizer or is not a method chain. + * + * @param {import('eslint').Rule.Node} node + * @returns {import('eslint').Rule.Node | null} + */ +function unwrapNonNormalizerMethodChain(node) { + if (!node || node.type !== 'CallExpression') return null; + // If it IS a valid normalizer, do NOT peel — the caller already handled that. + if (isPosixNormalizerCall(node)) return null; + + const callee = node.callee; + if ( + callee.type === 'MemberExpression' && + !callee.computed && + callee.property.type === 'Identifier' + ) { + const method = callee.property.name; + // String-mutation methods that commonly wrap path calls + if (method === 'replace' || method === 'replaceAll') { + return callee.object; + } + // .split(...).join(...) — callee.object is the .split() result; + // peel to the .split()'s receiver + if (method === 'join') { + const splitCall = callee.object; + if ( + splitCall && + splitCall.type === 'CallExpression' && + splitCall.callee.type === 'MemberExpression' && + !splitCall.callee.computed && + splitCall.callee.property.type === 'Identifier' && + splitCall.callee.property.name === 'split' + ) { + return splitCall.callee.object; + } + } + } + return null; +} + +module.exports = { + PATH_RETURNING_FNS, + isPathReturningCall, + isPosixSlashStringLiteral, + isPosixNormalizerCall, + unwrapString, + unwrapNonNormalizerMethodChain, +}; diff --git a/eslint-rules/no-bare-npm-exec.cjs b/eslint-rules/no-bare-npm-exec.cjs new file mode 100644 index 000000000..0640b5a0b --- /dev/null +++ b/eslint-rules/no-bare-npm-exec.cjs @@ -0,0 +1,154 @@ +'use strict'; + +/** + * no-bare-npm-exec + * + * Flag bare 'npm' invocations via execFileSync/spawnSync/spawn without + * `shell: true`. On Windows, `npm` is `npm.cmd` — a CMD batch file — and + * cannot be launched without a shell. + * + * ## What this enforces (G5) + * + * - `execFileSync('npm', ...)` / `spawnSync('npm', ...)` / `spawn('npm', ...)` + * whose options object (last arg, if ObjectExpression) does NOT set + * `shell: true`, `shell: isWindows`, or `shell: process.platform === 'win32'`. + * + * ## What this does NOT flag + * + * - `execSync('npm install', ...)` — execSync always runs through a shell + * (cmd.exe on Windows automatically resolves npm.cmd), so it is safe without + * `shell: true`. Only direct binary exec functions (execFileSync, spawnSync, + * spawn) bypass the shell and require explicit `{ shell: true }`. + * + * Message: Windows needs `npm.cmd` — pass `{ shell: true }`. + * + * DEFECT category: DEFECT.WINDOWS-TEST-PORTABILITY + */ + +/** @type {import('eslint').Rule.RuleModule} */ +const rule = { + meta: { + type: 'problem', + docs: { + description: + 'Disallow bare "npm" execFileSync/spawnSync/spawn/execSync without shell:true (fails on Windows)', + category: 'Portability', + }, + schema: [], + messages: { + bareNpmExec: + 'Bare "npm" invocation without { shell: true } is not portable ' + + '(DEFECT.WINDOWS-TEST-PORTABILITY): On Windows, npm is a CMD batch file ' + + '(npm.cmd) and requires a shell to execute. Pass { shell: true } as the ' + + 'options argument.', + }, + }, + + create(context) { + /** Functions that take (command, args, options) — direct binary exec, no shell */ + const EXEC_FILE_FNS = new Set(['execFileSync', 'spawnSync', 'spawn']); + + /** + * Returns the string value of a Literal node, or null. + * @param {import('eslint').Rule.Node} node + * @returns {string|null} + */ + function stringValue(node) { + if (node && node.type === 'Literal' && typeof node.value === 'string') { + return node.value; + } + return null; + } + + /** + * Returns the function name for a CallExpression callee (Identifier or + * MemberExpression), or null if not recognized. + * @param {import('eslint').Rule.Node} callee + * @returns {string|null} + */ + function getFnName(callee) { + if (callee.type === 'Identifier') return callee.name; + if ( + callee.type === 'MemberExpression' && + !callee.computed && + callee.property.type === 'Identifier' + ) { + return callee.property.name; + } + return null; + } + + /** + * Returns true if an ObjectExpression has `shell: true`, `shell: isWindows`, + * or `shell: process.platform === 'win32'`. + * @param {import('eslint').Rule.Node} optionsNode + * @returns {boolean} + */ + function hasShellTrue(optionsNode) { + if (!optionsNode || optionsNode.type !== 'ObjectExpression') return false; + for (const prop of optionsNode.properties) { + if (prop.type !== 'Property') continue; + const keyName = + prop.key.type === 'Identifier' + ? prop.key.name + : stringValue(prop.key); + if (keyName !== 'shell') continue; + const val = prop.value; + // shell: true + if (val.type === 'Literal' && val.value === true) return true; + // shell: isWindows / shell: IS_WINDOWS / shell: isWin / shell: onWindows + if (val.type === 'Identifier') { + const name = val.name; + if ( + name === 'isWindows' || + name === 'IS_WINDOWS' || + name === 'isWin' || + name === 'onWindows' + ) { + return true; + } + } + // shell: process.platform === 'win32' + if ( + val.type === 'BinaryExpression' && + (val.operator === '===' || val.operator === '==') && + val.left.type === 'MemberExpression' && + val.left.object.type === 'Identifier' && + val.left.object.name === 'process' && + val.left.property.type === 'Identifier' && + val.left.property.name === 'platform' && + val.right.type === 'Literal' && + val.right.value === 'win32' + ) { + return true; + } + } + return false; + } + + return { + CallExpression(node) { + const fnName = getFnName(node.callee); + if (!fnName) return; + + const args = node.arguments; + if (!args || args.length === 0) return; + + // Pattern A: execFileSync/spawnSync/spawn('npm', ...) + if (EXEC_FILE_FNS.has(fnName)) { + const firstArg = stringValue(args[0]); + if (firstArg !== 'npm') return; + + // Find last ObjectExpression argument as the options + const lastArg = args[args.length - 1]; + if (hasShellTrue(lastArg)) return; + + // No shell:true — report + context.report({ node, messageId: 'bareNpmExec' }); + } + }, + }; + }, +}; + +module.exports = rule; diff --git a/eslint-rules/no-crlf-fragile-split.cjs b/eslint-rules/no-crlf-fragile-split.cjs new file mode 100644 index 000000000..a55a07eb8 --- /dev/null +++ b/eslint-rules/no-crlf-fragile-split.cjs @@ -0,0 +1,418 @@ +'use strict'; + +/** + * no-crlf-fragile-split + * + * Flag CRLF-fragile file-content splitting and regex patterns in test files. + * Windows git-autocrlf causes readFileSync to return \r\n line endings; code + * that splits on bare `\n` or uses regexes with bare `\n` will silently + * mismatch on Windows. + * + * ## What this enforces + * + * G1 — a `.split('\n')` / `.split("\n")` CallExpression whose receiver is + * (transitively) a `readFileSync`/`fs.readFileSync` result — directly, + * via a chain, or via an Identifier that scope-resolves to a variable + * initialized from readFileSync. + * Message: use `.split(/\r?\n/)`. + * + * G2/G3 — a RegExpLiteral whose pattern contains a bare `\n` (a `\n` not + * part of `\r?\n` / `\r\n` / `[\r\n]` etc.) used as the pattern of a + * `.match`/`.test`/`.exec`/`.replace`/`.replaceAll`/`.split`/`.matchAll` + * call on a readFileSync-derived receiver. ALSO flags a RegExpLiteral + * with a bare `\n` whose source contains a markdown fence (```) or a + * frontmatter anchor (`^---`), since those shapes target file content. + * Message: use `\r?\n` (Windows git-autocrlf yields `\r\n`). + * + * ## Known boundaries + * + * The data-flow is scope-based: a readFileSync result is tracked via the + * immediate call-chain or a single variable binding initialized from + * readFileSync in the same file scope. A regex stored far from its use, or + * content obtained via a non-readFileSync read (e.g. fs.readFile callback, + * streams), may not be caught. G2/G3 additionally fires on fence/frontmatter + * regex shapes even when data-flow is indirect, to catch the most common + * markdown parsing patterns. + * + * DEFECT category: DEFECT.WINDOWS-CRLF-TEST-PORTABILITY + */ + +const { isWindowsExcludedNode } = require('./lib/platform-guard.cjs'); + +/** @type {import('eslint').Rule.RuleModule} */ +const rule = { + meta: { + type: 'problem', + docs: { + description: + 'Disallow CRLF-fragile file-content split and regex patterns in tests (fails on Windows with git-autocrlf)', + category: 'Portability', + }, + schema: [], + messages: { + crlfFragileSplit: + 'Splitting on literal "\\n" on readFileSync content is CRLF-fragile ' + + '(DEFECT.WINDOWS-CRLF-TEST-PORTABILITY): Windows git-autocrlf yields "\\r\\n" ' + + 'line endings. Use .split(/\\r?\\n/) instead.', + crlfFragileRegex: + 'RegExp with a bare "\\n" on readFileSync content is CRLF-fragile ' + + '(DEFECT.WINDOWS-CRLF-TEST-PORTABILITY): Windows git-autocrlf yields "\\r\\n" ' + + 'line endings. Use \\r?\\n (or [\\r\\n]) instead.', + }, + }, + + create(context) { + const sourceCode = context.sourceCode ?? context.getSourceCode(); + + // ── Helpers ───────────────────────────────────────────────────────────── + + /** + * Returns the string value of a Literal node, or null. + * @param {import('eslint').Rule.Node} node + * @returns {string|null} + */ + function stringValue(node) { + if (node && node.type === 'Literal' && typeof node.value === 'string') { + return node.value; + } + return null; + } + + /** + * Returns true if the node is a call to `readFileSync` or `fs.readFileSync`. + * @param {import('eslint').Rule.Node} node + * @returns {boolean} + */ + function isReadFileSyncCall(node) { + if (!node || node.type !== 'CallExpression') return false; + const callee = node.callee; + // readFileSync(...) + if (callee.type === 'Identifier' && callee.name === 'readFileSync') return true; + // fs.readFileSync(...) + if ( + callee.type === 'MemberExpression' && + !callee.computed && + callee.property.type === 'Identifier' && + callee.property.name === 'readFileSync' + ) { + return true; + } + return false; + } + + /** + * Returns true if `node` is (transitively) derived from a readFileSync call. + * + * Handles: + * - Direct: readFileSync(...) -- the node itself IS the readFileSync call + * - Chain: readFileSync(...).toString() etc. + * - Identifier resolved via scope to a variable initialized from readFileSync + * + * @param {import('eslint').Rule.Node} node + * @returns {boolean} + */ + function isReadFileSyncDerived(node) { + if (!node) return false; + + // Direct readFileSync call + if (isReadFileSyncCall(node)) return true; + + // MemberExpression: x.something — check the object + if (node.type === 'MemberExpression') { + return isReadFileSyncDerived(node.object); + } + + // CallExpression: x.something() — check object of the callee + if (node.type === 'CallExpression') { + if (isReadFileSyncCall(node)) return true; + if (node.callee.type === 'MemberExpression') { + return isReadFileSyncDerived(node.callee.object); + } + } + + // Identifier: resolve to its variable initializer via scope + if (node.type === 'Identifier') { + return resolveIdentifierToReadFileSync(node); + } + + return false; + } + + /** + * Given an Identifier node, walk the scope chain to find its binding, + * then check if the initializer is derived from readFileSync. + * @param {import('eslint').Rule.Node} identNode + * @returns {boolean} + */ + function resolveIdentifierToReadFileSync(identNode) { + if (typeof sourceCode.getScope !== 'function') return false; + + let scope; + try { + scope = sourceCode.getScope(identNode); + } catch (_) { + // If scope resolution fails (e.g. due to unsupported node type or + // parser version mismatch), conservatively return false (not flagged). + // This is an intentional boundary: an unresolvable scope produces a + // false negative rather than a spurious error. + return false; + } + if (!scope) return false; + + let s = scope; + while (s) { + const variable = s.variables.find(v => v.name === identNode.name); + if (variable) { + const defs = variable.defs; + if (!defs || defs.length === 0) return false; + const decl = defs[0].node; // VariableDeclarator + if (!decl || !decl.init) return false; + // Check the init is readFileSync-derived + return isReadFileSyncDerived(decl.init); + } + s = s.upper; + } + return false; + } + + /** + * Returns true if a RegExpLiteral has at least one FRAGILE bare \n — a \n + * that is not adequately protected against CRLF. + * + * Per-occurrence classification: every \n in the pattern is inspected + * individually. A \n is SAFE when ANY of these hold: + * 1. Immediately preceded by \r? (part of \r?\n) + * 2. Immediately preceded by \r (part of \r\n) + * 3. Inside a character class [...] that also contains \r + * (e.g. [\r\n], [^\r\n], [\n\r]) + * + * Everything else is FRAGILE: [^\n], [\n], or a bare \n in the main pattern. + * + * @param {import('eslint').Rule.Node} node — Literal with regex + * @returns {boolean} + */ + function hasBareLiteralNewline(node) { + if (!node || node.type !== 'Literal' || !node.regex) return false; + const pattern = node.regex.pattern; + if (!pattern.includes('\\n')) return false; + + // Walk the pattern, find every \n occurrence and classify it. + let i = 0; + // Track whether we are inside a [...] character class and whether + // the current class contains \r. + let inClass = false; + let classHasCarriageReturn = false; + let foundFragile = false; + + while (i < pattern.length) { + // Entering a character class + if (pattern[i] === '[' && !inClass) { + inClass = true; + classHasCarriageReturn = false; + i++; + // Skip optional ^ negation + if (i < pattern.length && pattern[i] === '^') i++; + // Skip ] if it appears immediately after [ or [^, where it is literal + if (i < pattern.length && pattern[i] === ']') i++; + continue; + } + + // Exiting a character class + if (pattern[i] === ']' && inClass) { + inClass = false; + i++; + continue; + } + + // Escape sequences inside the pattern + if (pattern[i] === '\\' && i + 1 < pattern.length) { + const next = pattern[i + 1]; + if (next === 'r') { + // \r — if inside a class, note it contains \r + if (inClass) classHasCarriageReturn = true; + i += 2; + continue; + } + if (next === 'n') { + // \n found — classify it + // Check if preceded by \r? or \r (look back in the raw pattern string) + // "preceded by" means the two chars before the current \\ are \r or \r? + const before2 = pattern.slice(Math.max(0, i - 2), i); // up to 2 chars before \\ + const safeByPrefix = + before2.endsWith('\\r?') || // \r?\n (but \r? is 3 chars, before is 2 — need to check before3) + before2.endsWith('\\r'); // \r\n + + // Re-check with a wider window for \r?\n (pattern chars: \r?\n = 5 chars) + const before3 = pattern.slice(Math.max(0, i - 3), i); + const safeByPrefixFull = + before3 === '\\r?' || // \r?\n + before2 === '\\r'; // \r\n + + if (inClass) { + // Inside a class: safe only if the class itself contains \r + if (!classHasCarriageReturn) { + foundFragile = true; + } + } else if (!safeByPrefixFull) { + foundFragile = true; + } + i += 2; + continue; + } + // Any other escape: skip both chars + i += 2; + continue; + } + + i++; + } + + return foundFragile; + } + + /** + * Returns true if a RegExpLiteral with a bare \n is used on a readFileSync- + * derived receiver via .match/.test/.exec/.replace/.replaceAll/.split/.matchAll. + * + * Two AST shapes: + * Shape A: str.match(/regex/) — regex is an ARG to the call. + * regex.parent = CallExpression (arg), callee.object = str + * Shape B: /regex/.test(str) — regex is the callee object. + * regex.parent = MemberExpression (the .test callee) + * regex.parent.parent = CallExpression, first arg = str + * + * @param {import('eslint').Rule.Node} regexNode — the RegExpLiteral + * @returns {boolean} + */ + function isRegexUsedOnFileContent(regexNode) { + const FILE_METHODS = new Set(['match', 'test', 'exec', 'replace', 'replaceAll', 'split', 'matchAll']); + const parent = regexNode.parent; + if (!parent) return false; + + // Shape A: str.match(regex) — regex is an argument; parent is CallExpression + if (parent.type === 'CallExpression') { + const callee = parent.callee; + if ( + callee && + callee.type === 'MemberExpression' && + !callee.computed && + callee.property.type === 'Identifier' && + FILE_METHODS.has(callee.property.name) + ) { + // regex must actually be one of the arguments (not the callee) + if (parent.arguments.includes(regexNode)) { + return isReadFileSyncDerived(callee.object); + } + } + return false; + } + + // Shape B: /regex/.test(str) — regex is the callee object. + // In this case, regexNode.parent is the MemberExpression (/regex/.test) + if (parent.type === 'MemberExpression' && !parent.computed) { + if ( + parent.object === regexNode && + parent.property.type === 'Identifier' && + FILE_METHODS.has(parent.property.name) + ) { + // parent.parent should be the CallExpression + const callExpr = parent.parent; + if (callExpr && callExpr.type === 'CallExpression' && callExpr.callee === parent) { + const args = callExpr.arguments; + if (args && args.length > 0) { + return isReadFileSyncDerived(args[0]); + } + } + } + } + + return false; + } + + /** + * Returns true if a RegExpLiteral pattern: + * - has a bare \n, AND + * - contains a markdown fence (```) or frontmatter anchor (^---) + * + * These shapes target file content by convention even without direct + * data-flow tracking. + * + * @param {import('eslint').Rule.Node} node — Literal with regex + * @returns {boolean} + */ + function isMarkdownOrFrontmatterRegex(node) { + if (!node || node.type !== 'Literal' || !node.regex) return false; + if (!hasBareLiteralNewline(node)) return false; + const pattern = node.regex.pattern; + // Markdown fence: ``` + if (pattern.includes('```')) return true; + // Frontmatter anchor: ^--- + if (/\^---/.test(pattern)) return true; + return false; + } + + // ── Per-file state ────────────────────────────────────────────────────── + + /** Collected G1 violations: {node} */ + const g1Violations = []; + /** Collected G2/G3 violations: {node} */ + const g2g3Violations = []; + + return { + // G1: .split('\n') on readFileSync-derived content + CallExpression(node) { + const callee = node.callee; + if ( + callee && + callee.type === 'MemberExpression' && + !callee.computed && + callee.property.type === 'Identifier' && + callee.property.name === 'split' + ) { + const args = node.arguments; + if (args && args.length >= 1) { + const argVal = stringValue(args[0]); + if (argVal === '\n') { + // Is the receiver derived from readFileSync? + if (isReadFileSyncDerived(callee.object)) { + g1Violations.push(node); + } + } + } + } + }, + + // G2/G3: RegExpLiteral with bare \n + Literal(node) { + if (!node.regex) return; + if (!hasBareLiteralNewline(node)) return; + + // Check G2/G3 via data-flow (receiver is readFileSync-derived) + if (isRegexUsedOnFileContent(node)) { + g2g3Violations.push(node); + return; + } + + // Also check G2/G3 via content shape (markdown fence or frontmatter) + if (isMarkdownOrFrontmatterRegex(node)) { + g2g3Violations.push(node); + } + }, + + 'Program:exit'() { + for (const node of g1Violations) { + if (!isWindowsExcludedNode(node, sourceCode)) { + context.report({ node, messageId: 'crlfFragileSplit' }); + } + } + for (const node of g2g3Violations) { + if (!isWindowsExcludedNode(node, sourceCode)) { + context.report({ node, messageId: 'crlfFragileRegex' }); + } + } + }, + }; + }, +}; + +module.exports = rule; diff --git a/eslint-rules/no-hardcoded-tmp.cjs b/eslint-rules/no-hardcoded-tmp.cjs new file mode 100644 index 000000000..1440df943 --- /dev/null +++ b/eslint-rules/no-hardcoded-tmp.cjs @@ -0,0 +1,110 @@ +'use strict'; + +/** + * no-hardcoded-tmp + * + * Flag hardcoded `/tmp/` paths passed to `fs.*` calls or `path.join()`. + * On Windows, `/tmp/` does not exist — use `os.tmpdir()` instead. + * + * ## What this enforces (G4) + * + * A string Literal whose value starts with `/tmp/` (or is exactly `/tmp`) + * passed as an argument to: + * - An `fs.(...)` call + * - A `path.join('/tmp/...', …)` call + * + * Message: use `os.tmpdir()`. + * + * DEFECT category: DEFECT.WINDOWS-TEST-PORTABILITY + */ + +/** @type {import('eslint').Rule.RuleModule} */ +const rule = { + meta: { + type: 'problem', + docs: { + description: + 'Disallow hardcoded /tmp/ paths in fs.* calls or path.join() (not portable to Windows)', + category: 'Portability', + }, + schema: [], + messages: { + hardcodedTmp: + 'Hardcoded "/tmp/" path is not portable (DEFECT.WINDOWS-TEST-PORTABILITY): ' + + 'Windows does not have /tmp/. Use os.tmpdir() to get the platform-appropriate ' + + 'temp directory instead.', + }, + }, + + create(context) { + /** + * Returns true if `node` is a string Literal starting with /tmp/ or equal to /tmp. + * @param {import('eslint').Rule.Node} node + * @returns {boolean} + */ + function isTmpLiteral(node) { + if (!node || node.type !== 'Literal') return false; + if (typeof node.value !== 'string') return false; + return node.value === '/tmp' || node.value.startsWith('/tmp/'); + } + + /** + * Returns true if this CallExpression is an `fs.(...)` call. + * @param {import('eslint').Rule.Node} node — CallExpression + * @returns {boolean} + */ + function isFsMethodCall(node) { + if (!node || node.type !== 'CallExpression') return false; + const callee = node.callee; + return ( + callee.type === 'MemberExpression' && + !callee.computed && + callee.object.type === 'Identifier' && + callee.object.name === 'fs' && + callee.property.type === 'Identifier' + ); + } + + /** + * Returns true if this CallExpression is a `path.join(...)` call. + * @param {import('eslint').Rule.Node} node — CallExpression + * @returns {boolean} + */ + function isPathJoinCall(node) { + if (!node || node.type !== 'CallExpression') return false; + const callee = node.callee; + return ( + callee.type === 'MemberExpression' && + !callee.computed && + callee.object.type === 'Identifier' && + callee.object.name === 'path' && + callee.property.type === 'Identifier' && + callee.property.name === 'join' + ); + } + + return { + CallExpression(node) { + // Check fs.(...) calls + if (isFsMethodCall(node)) { + for (const arg of node.arguments) { + if (isTmpLiteral(arg)) { + context.report({ node: arg, messageId: 'hardcodedTmp' }); + } + } + return; + } + + // Check path.join('/tmp/...', ...) calls + if (isPathJoinCall(node)) { + const args = node.arguments; + if (args && args.length > 0 && isTmpLiteral(args[0])) { + context.report({ node: args[0], messageId: 'hardcodedTmp' }); + } + } + }, + }; + }, +}; + +module.exports = rule; diff --git a/eslint-rules/no-path-literal-in-assert.cjs b/eslint-rules/no-path-literal-in-assert.cjs new file mode 100644 index 000000000..e7d01ad4a --- /dev/null +++ b/eslint-rules/no-path-literal-in-assert.cjs @@ -0,0 +1,190 @@ +'use strict'; + +/** + * no-path-literal-in-assert + * + * Flag assertion calls where a path-returning function (path.join, path.resolve, + * getGlobalConfigDir, …) is compared to a hardcoded POSIX-slash string literal. + * These assertions FAIL on Windows because path.join emits backslashes. + * + * Triggers on: + * assert.equal|strictEqual|deepEqual|deepStrictEqual(actual, expected) + * expect(actual).toBe|toEqual|toStrictEqual(expected) + * + * Out of scope — intentionally NOT reported: + * assert.notEqual|notStrictEqual(actual, expected) + * expect(actual).not.toBe|not.toEqual|not.toStrictEqual(expected) + * A path-vs-POSIX-literal INEQUALITY passes on Windows regardless of separator + * differences, so it does not exhibit the portability-defect shape this rule + * targets. + * + * Suppressed when: + * - The path operand is wrapped by a POSIX normalizer (replace/replaceAll/toPosixPath/…) + * - The assertion is inside a Windows-excluded block (platform guard, early-return, + * hoisted isWindows) as detected by platform-guard.cjs + * + * DEFECT category: DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT + * + * ── Known boundaries ─────────────────────────────────────────────────────────── + * + * (a) Name-based matching only. The rule recognises `path`, `os`, and the + * project resolver names listed in PATH_RETURNING_FNS by spelling alone. If + * a test file declares a LOCAL variable named `path` that shadows the real + * `path` module, that shadow is out of scope — the rule will still treat a + * `path.join(...)` call as path-returning. + * + * (b) Shallow operand inspection. Only the direct first/second argument of the + * assert call is inspected, plus one level of `String()` cast and one + * level of non-normalizer method-chain peeling (`.replace()`, `.replaceAll()`, + * `.split().join()`). Deeper wrapping — e.g. `.toLowerCase()` applied after + * a path call, or `fs.realpathSync(path.join(...))` — is NOT detected as a + * path-returning expression and will not trigger the rule. + * + * (c) Harmless no-op remedy. For explicit dir-pass-through assertions (where the + * path really does contain forward-slashes even on Windows), wrapping with + * `String().replace(/\\\\/g, '/')` is the correct suppression; on POSIX + * systems where `\\` never appears, the replace is a no-op and has zero cost. + */ + +const { + isPathReturningCall, + isPosixSlashStringLiteral, + isPosixNormalizerCall, + unwrapString, + unwrapNonNormalizerMethodChain, +} = require('./lib/portability-vocab.cjs'); + +const { isWindowsExcludedNode } = require('./lib/platform-guard.cjs'); + +/** @type {import('eslint').Rule.RuleModule} */ +const rule = { + meta: { + type: 'problem', + docs: { + description: + 'Disallow path-returning calls compared to hardcoded POSIX-slash literals in assertions (fails on Windows)', + category: 'Portability', + }, + schema: [], + messages: { + pathLiteral: + "Path-returning call compared to a hardcoded '/'-literal (DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT): " + + "fails on Windows where path.join emits '\\\\'. " + + "Normalize the actual: String().replace(/\\\\\\\\/g, '/') or .replaceAll(path.sep, '/').", + }, + }, + + create(context) { + const sourceCode = context.sourceCode ?? context.getSourceCode(); + + /** assert.equal / assert.strictEqual / assert.deepEqual / assert.deepStrictEqual */ + const ASSERT_EQUALITY_METHODS = new Set([ + 'equal', + 'strictEqual', + 'deepEqual', + 'deepStrictEqual', + ]); + + /** expect(actual).(expected) */ + const EXPECT_MATCHERS = new Set(['toBe', 'toEqual', 'toStrictEqual']); + + /** + * Returns true when `pathNode` represents a path call and `literalNode` is + * a POSIX slash literal, AND the path call is NOT already normalized. + * + * `rawPathNode` is the operand as-is (before unwrapping) — we check it for + * normalizer wrapping before stripping String(). + * + * Lookup order: + * 1. If rawPathNode IS a valid POSIX normalizer → no violation. + * 2. Unwrap String() cast → check if inner call is a path call. + * 3. If rawPathNode is a non-normalizer method chain (e.g. .replace(/foo/g,'/')) + * peel one layer to find if the receiver is a path-returning call. + */ + function isViolation(rawPathNode, rawLiteralNode) { + // Is the path-side already wrapped by a POSIX normalizer? + if (isPosixNormalizerCall(rawPathNode)) return false; + + // Unwrap String() cast to see the inner call + const pathNode = unwrapString(rawPathNode); + + if (isPathReturningCall(pathNode)) { + if (!isPosixSlashStringLiteral(rawLiteralNode)) return false; + return true; + } + + // C1: if rawPathNode is a non-normalizer method chain (.replace, .replaceAll, + // .split().join()) wrapping a path call, that is still a violation — the method + // chain does not perform a valid POSIX normalization. + const peeled = unwrapNonNormalizerMethodChain(rawPathNode); + if (peeled != null) { + const innerPath = unwrapString(peeled); + if (isPathReturningCall(innerPath) && isPosixSlashStringLiteral(rawLiteralNode)) { + return true; + } + } + + return false; + } + + return { + CallExpression(node) { + const callee = node.callee; + + // ── assert.(actual, expected) ────────────────────────────── + if ( + callee.type === 'MemberExpression' && + !callee.computed && + callee.object.type === 'Identifier' && + callee.object.name === 'assert' && + callee.property.type === 'Identifier' && + ASSERT_EQUALITY_METHODS.has(callee.property.name) + ) { + const args = node.arguments; + if (args.length < 2) return; + const actual = args[0]; + const expected = args[1]; + // Ignore 3rd arg (message) + + const violated = + isViolation(actual, expected) || + isViolation(expected, actual); + + if (violated && !isWindowsExcludedNode(node, sourceCode)) { + context.report({ node, messageId: 'pathLiteral' }); + } + return; + } + + // ── expect(actual).(expected) ───────────────────────────── + // Shape: CallExpression{ callee: MemberExpression{ object: CallExpression{callee: Identifier{expect}}, property: Identifier{} } } + if ( + callee.type === 'MemberExpression' && + !callee.computed && + callee.property.type === 'Identifier' && + EXPECT_MATCHERS.has(callee.property.name) && + callee.object.type === 'CallExpression' && + callee.object.callee.type === 'Identifier' && + callee.object.callee.name === 'expect' && + callee.object.arguments.length === 1 + ) { + const actual = callee.object.arguments[0]; // the arg to expect(...) + const matcherArgs = node.arguments; + if (matcherArgs.length < 1) return; + const expected = matcherArgs[0]; + + const violated = + isViolation(actual, expected) || + isViolation(expected, actual); + + if (violated && !isWindowsExcludedNode(node, sourceCode)) { + context.report({ node, messageId: 'pathLiteral' }); + } + return; + } + }, + }; + }, +}; + +module.exports = rule; diff --git a/eslint-rules/no-posix-mode-bit-assert.cjs b/eslint-rules/no-posix-mode-bit-assert.cjs new file mode 100644 index 000000000..33b6a6ded --- /dev/null +++ b/eslint-rules/no-posix-mode-bit-assert.cjs @@ -0,0 +1,409 @@ +'use strict'; + +/** + * no-posix-mode-bit-assert + * + * Flag assertion calls where a file-mode expression (e.g. fs.statSync(p).mode, + * or fs.statSync(p).mode & 0o777) is compared to an octal numeric literal. + * These assertions PASS on macOS/Linux but FAIL on Windows because Windows + * reports the DOS-attribute-derived mode (0o666 writable / 0o444 readonly), + * never the requested POSIX octal. + * + * Triggers on: + * assert.equal|strictEqual|deepEqual|deepStrictEqual(actual, expected) + * expect(actual).toBe|toEqual|toStrictEqual(expected) + * + * A "file-mode expression" is one that: + * M0. Contains a `.mode` MemberExpression (non-computed): + * x.mode, fs.statSync(p).mode, x.mode & 0oNNN + * M1. Contains a computed `['mode']` MemberExpression: + * x['mode'], stat['mode'] & 0o777 + * M2. Is a variable whose binding (resolved via scope) is initialized to a + * mode expression: `const m = stat.mode` / `const m = stat['mode']` + * Conservative: only flags when binding resolves in-file and is not + * reassigned before the assertion. + * M3. Is a variable destructured as `mode` from an object: + * `const { mode } = fs.statSync(p)` — the `mode` binding is a mode expr. + * Conservative: same resolution rules as M2. + * M4. Is a CallExpression to `Number`/`parseInt` whose first argument contains + * a mode expression (recursive): `Number(stat.mode & 0o777)`, + * `parseInt(stat.mode, 8)`. + * + * The violation is flagged when: + * 1. One operand is (or contains/resolves-to) a mode expression, AND + * 2. An octal numeric literal appears either as the other operand, OR + * as the right-hand side of the bitwise expression containing the mode. + * + * Suppressed when: + * - The assertion node is inside a Windows-excluded block (platform guard, + * early-return guard, hoisted isWindows) as detected by platform-guard.cjs. + * + * DEFECT category: DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT + * + * ── Known boundaries ─────────────────────────────────────────────────────────── + * + * (a) The rule detects `.mode` / `['mode']` by property name. It assumes any + * `.mode` or `['mode']` alongside an octal literal in an equality assertion + * is a filesystem mode check. A non-fs `.mode` or `['mode']` compared to an + * octal literal IS flagged — the defect shape (POSIX-mode assertion that + * fails on Windows) is the primary concern, and false positives for non-fs + * `.mode` vs an octal literal are vanishingly rare in test code. + * + * (b) Variable-capture (M2) and destructure (M3) detection is scope-based. + * When a binding RESOLVES in-file to a mode expression and is not reassigned, + * the variable is treated as a mode expression. An unresolvable or reassigned + * identifier is NOT flagged (conservative — avoids false positives on + * non-fs identifiers or imported constants). + * + * (c) The `node:test` `test(name, { skip: isWindows ? … : false }, fn)` OPTION + * object is NOT recognized as a platform guard. To make a mode-bit assertion + * POSIX-only use an `if (process.platform !== 'win32')` guard (or an early- + * return guard) inside the callback — the rule recognizes those shapes. + * + * (d) Octal detection covers `0o`/`0O` prefix literals. Legacy `0NNN` octal + * literals (banned by strict mode and most linters) are not a concern in + * modern test files and are not handled. + */ + +const { isWindowsExcludedNode } = require('./lib/platform-guard.cjs'); + +/** @type {import('eslint').Rule.RuleModule} */ +const rule = { + meta: { + type: 'problem', + docs: { + description: + 'Disallow asserting POSIX file mode bits compared to octal literals (fails on Windows)', + category: 'Portability', + }, + schema: [], + messages: { + posixModeBit: + 'Asserting a POSIX file mode (DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT): Windows reports ' + + '0o666/0o444, not the requested octal. Gate this precondition on ' + + "`if (process.platform !== 'win32')` and keep the platform-independent " + + 'behavioral assertion running on every OS.', + }, + }, + + create(context) { + const sourceCode = context.sourceCode ?? context.getSourceCode(); + + /** assert.equal / assert.strictEqual / assert.deepEqual / assert.deepStrictEqual */ + const ASSERT_EQUALITY_METHODS = new Set([ + 'equal', + 'strictEqual', + 'deepEqual', + 'deepStrictEqual', + ]); + + /** expect(actual).(expected) */ + const EXPECT_MATCHERS = new Set(['toBe', 'toEqual', 'toStrictEqual']); + + /** + * Returns true when the given AST node IS an octal numeric literal. + * Matches `0o`/`0O` prefix form (ES6+). Raw source is checked because + * `node.value` for `0o644` is `420` (decimal) — the same integer can be + * written as `0x1A4` or `420` without being a mode-bit assertion. + * + * @param {import('eslint').Rule.Node} node + * @returns {boolean} + */ + function isOctalLiteral(node) { + if (!node || node.type !== 'Literal') return false; + if (typeof node.value !== 'number') return false; + // Check raw source representation via sourceCode + const raw = sourceCode.getText(node); + return raw.startsWith('0o') || raw.startsWith('0O'); + } + + /** + * Returns true when `node` is a syntactic mode expression — one that + * directly contains a `.mode` or `['mode']` MemberExpression anywhere + * within it (including inside BinaryExpression and Number/parseInt wrappers). + * + * Recognized shapes (M0, M1, M4): + * M0: x.mode — non-computed MemberExpression + * M0: fs.statSync(p).mode — chained non-computed + * M0: x.mode & 0o777 — .mode inside a BinaryExpression + * M1: x['mode'] — computed MemberExpression, string 'mode' + * M1: x['mode'] & 0o777 — computed .mode inside BinaryExpression + * M4: Number(x.mode & 0o777) — Number() wrapping a mode expression + * M4: parseInt(x.mode, 8) — parseInt() wrapping a mode expression + * + * Does NOT resolve variable references (that is done by isModeExpression). + * + * @param {import('eslint').Rule.Node} node + * @returns {boolean} + */ + function containsSyntacticModeExpression(node) { + if (!node) return false; + + // M0: Non-computed MemberExpression with property name 'mode' + if ( + node.type === 'MemberExpression' && + !node.computed && + node.property.type === 'Identifier' && + node.property.name === 'mode' + ) { + return true; + } + + // M1: Computed MemberExpression with string property 'mode' + if ( + node.type === 'MemberExpression' && + node.computed && + node.property.type === 'Literal' && + node.property.value === 'mode' + ) { + return true; + } + + // BinaryExpression: recurse left and right (covers x.mode & 0o777) + if (node.type === 'BinaryExpression') { + return ( + containsSyntacticModeExpression(node.left) || + containsSyntacticModeExpression(node.right) + ); + } + + // M4: Number(...) or parseInt(...) — recurse into the first argument + if ( + node.type === 'CallExpression' && + node.callee.type === 'Identifier' && + (node.callee.name === 'Number' || node.callee.name === 'parseInt') && + node.arguments.length >= 1 + ) { + return containsSyntacticModeExpression(node.arguments[0]); + } + + return false; + } + + /** + * Resolve a bare Identifier through the ESLint scope to determine whether + * its binding is initialized to a mode expression (M2/M3). + * + * Returns true when ALL of the following hold: + * - A VariableDeclarator binding for the name is found in-file scope. + * - The declarator's init is a mode expression: + * M2: `const m = stat.mode` / `const m = stat['mode']` — init is a + * MemberExpression (or expression) containing a mode MemberExpression. + * M3: `const { mode } = fs.statSync(p)` — the declarator id is an + * ObjectPattern that includes a property keyed 'mode' matching + * this identifier's name. + * - The variable is NOT reassigned after initialization. + * + * Returns false (conservative) when: + * - No in-file binding is found (could be an import, global, or parameter). + * - The binding does not resolve to a mode expression. + * - The variable is reassigned. + * + * @param {import('eslint').Rule.Node} identNode — the Identifier AST node + * @returns {boolean} + */ + function resolveIdentifierToModeExpression(identNode) { + if (!identNode || identNode.type !== 'Identifier') return false; + if (typeof sourceCode.getScope !== 'function') return false; + + let scope; + try { + scope = sourceCode.getScope(identNode); + } catch (_) { + return false; + } + if (!scope) return false; + + const name = identNode.name; + + // Walk scope chain innermost-first to find the nearest binding. + let s = scope; + while (s) { + const variable = s.variables.find(v => v.name === name); + if (variable) { + // Found an in-file binding. + const defs = variable.defs; + if (!defs || defs.length === 0) return false; // no declarator (e.g. parameter) + + const decl = defs[0].node; // VariableDeclarator + if (!decl) return false; + + // Check for reassignment: any write reference that is NOT the init. + const isReassigned = variable.references.some(ref => ref.isWrite() && !ref.init); + if (isReassigned) return false; + + // M3: ObjectPattern destructure — `const { mode } = ...` + // The binding matches if the declarator id is an ObjectPattern AND + // the destructured key for this identifier's name is 'mode'. + if (decl.id && decl.id.type === 'ObjectPattern') { + const modeProperty = decl.id.properties.find( + prop => + prop.type === 'Property' && + prop.key && + ((prop.key.type === 'Identifier' && prop.key.name === 'mode') || + (prop.key.type === 'Literal' && prop.key.value === 'mode')) && + prop.value && + prop.value.type === 'Identifier' && + prop.value.name === name + ); + if (modeProperty) return true; + return false; // ObjectPattern without matching 'mode' key + } + + // M2: Simple declarator — `const m = stat.mode` or `const m = stat['mode']` + if (!decl.init) return false; + return containsSyntacticModeExpression(decl.init); + } + s = s.upper; + } + + // No in-file binding found — conservative: do not flag. + return false; + } + + /** + * Returns true when `node` is or contains a file-mode expression. + * Extends containsSyntacticModeExpression with M2/M3 scope-based resolution + * for bare Identifiers. + * + * @param {import('eslint').Rule.Node} node + * @returns {boolean} + */ + function isModeExpression(node) { + if (!node) return false; + + // Syntactic check first (M0, M1, M4) + if (containsSyntacticModeExpression(node)) return true; + + // M2/M3: bare Identifier — resolve via scope + if (node.type === 'Identifier') { + return resolveIdentifierToModeExpression(node); + } + + // BinaryExpression: recurse (picks up `m & 0o777` where m is a mode alias) + if (node.type === 'BinaryExpression') { + return isModeExpression(node.left) || isModeExpression(node.right); + } + + // M4: Number/parseInt — recurse into first argument + if ( + node.type === 'CallExpression' && + node.callee.type === 'Identifier' && + (node.callee.name === 'Number' || node.callee.name === 'parseInt') && + node.arguments.length >= 1 + ) { + return isModeExpression(node.arguments[0]); + } + + return false; + } + + /** + * Returns true when `node` contains an octal literal anywhere within it. + * This covers: + * - 0o644 — direct octal literal + * - x.mode & 0o777 — octal inside a BinaryExpression (the mask) + * - Number(x.mode & 0o777) — octal inside a wrapper + * + * @param {import('eslint').Rule.Node} node + * @returns {boolean} + */ + function containsOctalLiteral(node) { + if (!node) return false; + if (isOctalLiteral(node)) return true; + if (node.type === 'BinaryExpression') { + return containsOctalLiteral(node.left) || containsOctalLiteral(node.right); + } + // Also recurse into Number/parseInt wrappers for the octal check + if ( + node.type === 'CallExpression' && + node.callee.type === 'Identifier' && + (node.callee.name === 'Number' || node.callee.name === 'parseInt') && + node.arguments.length >= 1 + ) { + return containsOctalLiteral(node.arguments[0]); + } + return false; + } + + /** + * Returns true when the pair of operands represents a POSIX-mode-bit assertion: + * - One operand is (or resolves to) a mode expression, AND + * - An octal literal appears somewhere in either operand (as a mask or as + * the comparison value). + * + * Both operand orderings are checked by the caller. + * + * @param {import('eslint').Rule.Node} a - first operand + * @param {import('eslint').Rule.Node} b - second operand + * @returns {boolean} + */ + function isModeBitViolation(a, b) { + const aModeExpr = isModeExpression(a); + const bModeExpr = isModeExpression(b); + + if (!aModeExpr && !bModeExpr) return false; + + // At least one operand contains a .mode expression. + // Check if any octal literal appears in either operand. + const aHasOctal = containsOctalLiteral(a); + const bHasOctal = containsOctalLiteral(b); + + return aHasOctal || bHasOctal; + } + + return { + CallExpression(node) { + const callee = node.callee; + + // ── assert.(actual, expected) ────────────────────────────── + if ( + callee.type === 'MemberExpression' && + !callee.computed && + callee.object.type === 'Identifier' && + callee.object.name === 'assert' && + callee.property.type === 'Identifier' && + ASSERT_EQUALITY_METHODS.has(callee.property.name) + ) { + const args = node.arguments; + if (args.length < 2) return; + const actual = args[0]; + const expected = args[1]; + + if (isModeBitViolation(actual, expected) && !isWindowsExcludedNode(node, sourceCode)) { + context.report({ node, messageId: 'posixModeBit' }); + } + return; + } + + // ── expect(actual).(expected) ───────────────────────────── + // Shape: CallExpression{ callee: MemberExpression{ + // object: CallExpression{callee: Identifier{expect}}, + // property: Identifier{} + // }} + if ( + callee.type === 'MemberExpression' && + !callee.computed && + callee.property.type === 'Identifier' && + EXPECT_MATCHERS.has(callee.property.name) && + callee.object.type === 'CallExpression' && + callee.object.callee.type === 'Identifier' && + callee.object.callee.name === 'expect' && + callee.object.arguments.length === 1 + ) { + const actual = callee.object.arguments[0]; // the arg to expect(...) + const matcherArgs = node.arguments; + if (matcherArgs.length < 1) return; + const expected = matcherArgs[0]; + + if (isModeBitViolation(actual, expected) && !isWindowsExcludedNode(node, sourceCode)) { + context.report({ node, messageId: 'posixModeBit' }); + } + return; + } + }, + }; + }, +}; + +module.exports = rule; diff --git a/eslint-rules/no-unguarded-nonportable-exec.cjs b/eslint-rules/no-unguarded-nonportable-exec.cjs new file mode 100644 index 000000000..1865ee67d --- /dev/null +++ b/eslint-rules/no-unguarded-nonportable-exec.cjs @@ -0,0 +1,258 @@ +'use strict'; + +/** + * no-unguarded-nonportable-exec + * + * Flag test files that BOTH make a fixture executable via chmod (exec-bit set) + * AND invoke it with `sh -c` / `bash -c` — without a Windows platform guard. + * + * ## Why + * + * Windows Git Bash (msys2) does not honour Node's chmod exec bit for + * PATH-executing extension-less scripts. A test that (a) makes a fixture + * executable via chmodSync and (b) runs it with `sh -c`/`bash -c` will pass + * on Mac/Linux but fail only in the CI `test (windows-latest, *)` / + * `full test (windows-latest, *)` lanes, producing a hard-to-diagnose + * false-negative gate. See CONTEXT.md → DEFECT.WINDOWS-TEST-PORTABILITY. + * + * ## What this enforces (Program-level co-occurrence) + * + * Within a single file, detects the combination: + * - makesExecutable: any `chmod`/`chmodSync(path, 0oNNN)` call where the + * octal 2nd arg has exec bits set (`0oNNN & 0o111 !== 0`) + * - shellDashC: any `execFileSync`/`spawnSync`/`spawn`/`exec`/`execSync` + * call whose command arg is `sh`/`bash`/`/bin/sh`/`/bin/bash` with a `-c` + * arg in array form — or a string literal arg containing `sh -c`/`bash -c` + * + * At Program:exit, reports each unguarded shellDashC node when the file also + * contains a chmod-exec-bit call. Guarded means the node is inside an + * `isWindowsExcludedNode` block (platform guard / early-return / hoisted isWindows). + * + * ## Remediation + * + * Gate the bare-command execution behind `if (process.platform !== 'win32')`, + * or invoke via an explicit interpreter (`sh ` instead of `sh -c `). + * + * DEFECT category: DEFECT.WINDOWS-TEST-PORTABILITY + */ + +const { isWindowsExcludedNode } = require('./lib/platform-guard.cjs'); + +/** @type {import('eslint').Rule.RuleModule} */ +const rule = { + meta: { + type: 'problem', + docs: { + description: + 'Disallow unguarded chmod exec-bit + sh/bash -c combinations in tests (fails on Windows Git Bash)', + category: 'Portability', + }, + schema: [], + messages: { + nonportableExec: + 'chmod exec-bit + sh/bash -c without a Windows guard ' + + '(DEFECT.WINDOWS-TEST-PORTABILITY): Windows Git Bash (msys2) ignores ' + + "the exec bit for PATH-executed extension-less scripts. Gate the " + + "execution on `if (process.platform !== 'win32')` or invoke via an " + + 'explicit interpreter `sh ` instead of `sh -c`.', + }, + }, + + create(context) { + const sourceCode = context.sourceCode ?? context.getSourceCode(); + + /** + * Shell commands whose first argument is the shell name. + * Matches execFileSync, spawnSync, spawn, exec, execSync. + */ + const SHELL_EXEC_FN_NAMES = new Set([ + 'execFileSync', + 'spawnSync', + 'spawn', + 'exec', + 'execSync', + ]); + + /** + * Bare shell names (possibly with /bin/ or /usr/bin/ prefix). + * The path prefix is stripped when comparing. + */ + const SHELL_NAMES = new Set(['sh', 'bash']); + + /** + * Returns the string value of a node if it's a string literal, else null. + * @param {import('eslint').Rule.Node} node + * @returns {string|null} + */ + function stringValue(node) { + if (node && node.type === 'Literal' && typeof node.value === 'string') { + return node.value; + } + return null; + } + + /** + * Returns true if `name` (possibly /bin/sh or /usr/bin/bash etc.) is sh/bash. + * @param {string} name + * @returns {boolean} + */ + function isShellName(name) { + // Strip /bin/ or /usr/bin/ prefix + const bare = name.replace(/^(?:\/usr)?\/bin\//, ''); + return SHELL_NAMES.has(bare); + } + + /** + * Returns true when this CallExpression is a `sh`/`bash -c` invocation in + * array form: + * execFileSync('bash', ['-c', ...]) + * spawnSync('/bin/sh', ['-c', ...]) + * etc. + * + * @param {import('eslint').Rule.Node} node — CallExpression + * @returns {boolean} + */ + function isShellDashCArrayForm(node) { + if (node.type !== 'CallExpression') return false; + + // Callee must be one of our shell exec functions (possibly member expr) + const callee = node.callee; + let fnName = null; + if (callee.type === 'Identifier') { + fnName = callee.name; + } else if ( + callee.type === 'MemberExpression' && + !callee.computed && + callee.property.type === 'Identifier' + ) { + fnName = callee.property.name; + } + if (!fnName || !SHELL_EXEC_FN_NAMES.has(fnName)) return false; + + const args = node.arguments; + if (!args || args.length < 2) return false; + + // First arg: shell name + const shellArg = stringValue(args[0]); + if (!shellArg || !isShellName(shellArg)) return false; + + // Second arg: must be an ArrayExpression containing '-c' + const secondArg = args[1]; + if (!secondArg || secondArg.type !== 'ArrayExpression') return false; + + // '-c' must be the FIRST element: sh/bash -c → args = ['-c', ] + // A script that happens to receive '-c' later (e.g. [fixturePath, '-c']) + // is NOT a shell -c invocation. + const firstEl = secondArg.elements[0]; + return stringValue(firstEl) === '-c'; + } + + /** + * Returns true when this CallExpression is a string-literal form containing + * `sh -c` or `bash -c`: + * exec('sh -c "run.sh"') + * execSync('bash -c script') + * + * @param {import('eslint').Rule.Node} node — CallExpression + * @returns {boolean} + */ + function isShellDashCStringForm(node) { + if (node.type !== 'CallExpression') return false; + + const callee = node.callee; + let fnName = null; + if (callee.type === 'Identifier') { + fnName = callee.name; + } else if ( + callee.type === 'MemberExpression' && + !callee.computed && + callee.property.type === 'Identifier' + ) { + fnName = callee.property.name; + } + if (!fnName || !SHELL_EXEC_FN_NAMES.has(fnName)) return false; + + const args = node.arguments; + if (!args || args.length < 1) return false; + + // First arg may be a string literal containing 'sh -c' or 'bash -c' + const firstArg = stringValue(args[0]); + if (!firstArg) return false; + + // Anchor to the start of the command string (allowing leading whitespace and + // an optional absolute-path prefix like /bin/ or /usr/bin/). + // This prevents matching 'sh -c' embedded mid-string in data, e.g. + // exec('printf "sh -c"') or exec('echo run sh -c later') + return /^\s*(?:\/\S+\/)?(?:bash|sh)\s+-c\b/.test(firstArg); + } + + /** + * Returns true when the CallExpression is a chmod/chmodSync call whose + * second arg is an octal literal with at least one exec bit set. + * + * @param {import('eslint').Rule.Node} node — CallExpression + * @returns {boolean} + */ + function isChmodExecBit(node) { + if (node.type !== 'CallExpression') return false; + + const callee = node.callee; + let fnName = null; + if (callee.type === 'Identifier') { + fnName = callee.name; + } else if ( + callee.type === 'MemberExpression' && + !callee.computed && + callee.property.type === 'Identifier' + ) { + fnName = callee.property.name; + } + if (!fnName || (fnName !== 'chmod' && fnName !== 'chmodSync')) return false; + + const args = node.arguments; + if (!args || args.length < 2) return false; + + const modeArg = args[1]; + if (!modeArg || modeArg.type !== 'Literal') return false; + if (typeof modeArg.value !== 'number') return false; + + // Check it's an octal literal (raw source starts with 0o or 0O) + const raw = sourceCode.getText(modeArg); + if (!raw.startsWith('0o') && !raw.startsWith('0O')) return false; + + // Check exec bit is set + return (modeArg.value & 0o111) !== 0; + } + + // ── Per-file state ────────────────────────────────────────────────────────── + + /** Whether the file contains at least one chmod exec-bit call. */ + let fileHasChmodExecBit = false; + + /** Collection of sh/bash -c nodes found in this file. */ + const shellDashCNodes = []; + + return { + CallExpression(node) { + if (isChmodExecBit(node)) { + fileHasChmodExecBit = true; + } + if (isShellDashCArrayForm(node) || isShellDashCStringForm(node)) { + shellDashCNodes.push(node); + } + }, + + 'Program:exit'() { + if (!fileHasChmodExecBit) return; + + for (const shellNode of shellDashCNodes) { + if (!isWindowsExcludedNode(shellNode, sourceCode)) { + context.report({ node: shellNode, messageId: 'nonportableExec' }); + } + } + }, + }; + }, +}; + +module.exports = rule; diff --git a/eslint-rules/normalize-path-in-content.cjs b/eslint-rules/normalize-path-in-content.cjs new file mode 100644 index 000000000..b31522b78 --- /dev/null +++ b/eslint-rules/normalize-path-in-content.cjs @@ -0,0 +1,479 @@ +'use strict'; + +/** + * normalize-path-in-content + * + * Flag: a path-returning function result (PATH_RETURNING_FNS call, or a + * variable that scope-resolves to one) interpolated into a template literal + * (`${ … }`) or string concatenation that is CONTENT — heuristic: the + * template/string also contains a genuine reference marker (see shapes below) + * WITHOUT the path flowing through a POSIX normalizer + * (isPosixNormalizerCall: `.replace(/\\/g,'/')`, `toPosixPath`, etc.). + * + * The canonical defect is computePathPrefix returning `${resolvedTarget}/` + * verbatim on Windows (PR #1622) — backslashes leaked into `@~/.claude/...` + * markdown content, breaking cross-platform substring checks and producing + * malformed @-references in Windsurf workflow files. + * + * References: + * DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT (CONTEXT.md) + * RULESET.CONTENT-PATH-NORMALIZATION (CONTEXT.md) + * + * Message: + * Cite RULESET.CONTENT-PATH-NORMALIZATION: normalize at source + * `String().replace(/\\/g,'/')` before interpolating into content. + * + * ── Known boundaries ──────────────────────────────────────────────────────── + * + * (a) Name-based matching only. `path`, `os`, and the project resolver names in + * PATH_RETURNING_FNS are recognized by spelling. A local variable that + * shadows one of these names is out of scope. + * + * (b) Shallow expression inspection. Only the direct expression inside `${ }` + * (or a concatenation operand) is checked, plus one level of String() cast. + * Deeper wrapping (e.g. `.toLowerCase()` after a path call) is not detected + * as a path-returning expression and will not trigger the rule. + * + * (c) Content heuristic — two shapes are recognized: + * + * Shape (a) — quasis contain an @-reference or home-dir prefix marker: + * `@~/`, `@$`, `@/`, `$HOME`, `~/` anywhere in the template's static + * parts. A bare `@` that is NOT immediately followed by `~`, `$`, or `/` + * (e.g. an email address or attribution line) does NOT qualify. + * + * Shape (b) — per-expression: quasis[i+1].raw starts with a forward slash + * and contains `.md` or `.json` at the end of a path component. This + * catches `${computePathPrefix(t)}/commands/gsd/x.md` and + * `@${getGlobalConfigDir()}/agents/foo.md` without requiring config-dir + * markers in CONTENT_MARKERS. + * + * Config-dir substrings (e.g. `/.claude`, `/commands`, `/skills`, etc.) + * are NOT content markers — they appeared in log/error/diagnostic strings + * too often and generated false positives. Shape (b) covers the genuine + * content-emit cases without those FPs. + * + * Bare `.md`/`.json` tokens in plain prose (e.g. "see PROJECT.md") do NOT + * qualify — they carry no separator-bearing path context that could be + * tainted by backslashes. Pure log messages, filesystem paths passed to + * fs.* functions, and Error messages that lack these markers are NOT flagged. + * + * (d) Suppression by call context. A path expression inside a `fs.*` call + * argument (readFileSync, writeFileSync, join, resolve, etc.), a + * `console.*` call, `new Error(...)`, a bare `Error(...)` / `TypeError(...)` + * / `RangeError(...)` etc. (any CallExpression whose callee is an Identifier + * whose name ends in `Error`), or a `require(...)` is not flagged — these + * are real FS paths or diagnostics, not content. + * + * (e) Indirect data-flow is NOT tracked. If a path-returning call result is + * stored in a variable or object field and that variable is later + * interpolated into a content template (e.g. `${globalSkillDir}/SKILL.md` + * → `@${entry.ref}` as in src/init.cts), the rule DOES NOT detect the + * violation — it only flags direct path-returning call expressions inside + * `${ }`. Indirect content-path-leaks rely on + * RULESET.CONTENT-PATH-NORMALIZATION discipline (normalize at source) and + * code review. The one known indirect leak (src/init.cts cmdAgentSkills + * `entry.ref` building) is fixed by normalizing at the content-emit site. + * + * (f) path.basename is excluded from PATH_RETURNING_FNS for this rule. + * path.basename() returns only the final filename component — it cannot + * contain directory separators, so it is safe to interpolate into content + * without normalization. Only calls that produce separator-bearing paths + * (path.join, path.resolve, path.dirname, path.relative, path.normalize, + * os.homedir, os.tmpdir, and the project resolver functions) are flagged. + */ + +const { + PATH_RETURNING_FNS, + isPosixNormalizerCall, + unwrapString, +} = require('./lib/portability-vocab.cjs'); + +// ── Rule-local path-fn set: PATH_RETURNING_FNS minus path.basename ───────── +// +// path.basename() returns a filename with no directory separators, so it +// cannot leak backslashes into content. All other entries in PATH_RETURNING_FNS +// DO produce separator-bearing paths and ARE checked by this rule. +// +// Note: toPosixPath remains in this set intentionally (it IS a path-returning +// function), but isContentPathReturningCall is never reached for a toPosixPath +// call because isPosixNormalizerCall short-circuits first in isUnnormalizedPathExpression. +const CONTENT_PATH_FNS = new Set(PATH_RETURNING_FNS.filter(fn => fn !== 'path.basename')); + +/** + * Returns true when `node` (a CallExpression) is a call to one of the + * CONTENT_PATH_FNS entries (PATH_RETURNING_FNS minus path.basename). + * + * @param {import('eslint').Rule.Node} node + * @returns {boolean} + */ +function isContentPathReturningCall(node) { + if (!node || node.type !== 'CallExpression') return false; + const callee = node.callee; + + // Dotted call: path.join, os.homedir, etc. + if ( + callee.type === 'MemberExpression' && + !callee.computed && + callee.object.type === 'Identifier' && + callee.property.type === 'Identifier' + ) { + const dotted = `${callee.object.name}.${callee.property.name}`; + if (CONTENT_PATH_FNS.has(dotted)) return true; + } + + // Bare call: getGlobalConfigDir(), resolveKimiGlobalDir(), etc. + if (callee.type === 'Identifier') { + if (CONTENT_PATH_FNS.has(callee.name)) return true; + } + + return false; +} + +// ── Content-heuristic markers ────────────────────────────────────────────── +// +// A template/string is considered "content" (markdown @-references, workflow +// bodies, generated documentation) when its STATIC parts (quasis) contain at +// least one genuine reference marker. Two shapes are recognized: +// +// Shape (a) — explicit @-reference / home-dir prefix in quasis: +// '@~' → @~/.claude/ reference (home-dir @-reference form) +// '@$' → @${prefix}/commands/... reference (interpolated @-reference) +// '@/' → @/path/... reference (root-relative @-reference form) +// '$HOME' → $HOME/.cursor/... in generated workflow content +// '~/' → ~/. shorthand for home-dir references in content +// +// NOTE: bare '@' is deliberately excluded — it is too broad and would +// match email addresses and attribution lines (@author), causing false +// positives. Only the genuine @-reference shapes (@~, @$, @/) are matched. +// +// Shape (b) — path-returning interpolation immediately before a .md/.json +// file reference (per-expression quasi check, not template-wide): +// quasis[i+1].raw matches /^\/[^\s`]*\.(md|json)(\b|$|\/)/ — the text +// immediately following expression `i` starts with `/...path.md` or +// `/...path.json`, indicating the expression is a path prefix for a +// content file reference. This catches `${computePathPrefix(t)}/commands/ +// gsd/x.md` and `@${getGlobalConfigDir('claude')}/commands/gsd/help.md` +// without requiring config-dir markers in CONTENT_MARKERS. +// +// Deliberately excluded from CONTENT_MARKERS (were Tier 2 / Tier 3): +// Config-dir substrings (`/.claude`, `/.cursor`, `/.gemini`, `/.config`, +// etc.) and artifact-path segments (`/commands`, `/agents`, `/skills`, +// `/workflows`, `/rules`, `/gsd`) — these are too broad as standalone +// markers and generate false positives when interpolated into log/error/ +// diagnostic strings that mention config-dir paths. Shape (b) above covers +// the genuine content-emit cases without the FP risk. +// +// '@' — too broad; matches email addresses and @author attributions. +// Only the genuine @-reference prefixes (@~, @$, @/) are kept. +// '.md' — too broad; appears in plain prose ("see PROJECT.md") with no +// separator-bearing path context. +// '.json' — same rationale as '.md'. +const CONTENT_MARKERS = [ + // Shape (a) — @-reference / home-dir prefix markers + '@~', // @~/.claude/ home-dir @-reference form + '@$', // @${prefix}/... interpolated @-reference form + '@/', // @/path/... root-relative @-reference form + '$HOME', // $HOME/.cursor/ path prefix in content + '~/', // ~/. shorthand in content +]; + +// ── Shape (b): per-expression quasi marker ─────────────────────────────────── +// +// Applied per-expression in TemplateLiteral: quasis[i+1].raw must start with +// a forward slash and contain `.md` or `.json` before the next whitespace or +// end of the quasi string. This matches `/commands/gsd/x.md`, +// `/skills/foo/SKILL.md`, `/help.json`, etc. without requiring a config-dir +// marker in CONTENT_MARKERS. +// +// The check is: /^\/[^\s`]*\.(md|json)(\b|\/|$)/ against the raw quasi text. +// The `\b` / `\/` / end-of-string ensures the extension is a terminal component +// (not a `.md` substring in the middle of a word). +const QUASI_MD_JSON_RE = /^\/[^\s`]*\.(md|json)(\b|\/|$)/; + +// ── FS-call suppression: callee names that indicate a real filesystem path ─ +const FS_OBJECT_NAMES = new Set(['fs', 'path', 'os']); +const FS_METHOD_NAMES = new Set([ + 'readFileSync', 'writeFileSync', 'existsSync', 'statSync', + 'mkdirSync', 'mkdtempSync', 'readdirSync', 'unlinkSync', + 'copyFileSync', 'renameSync', 'lstatSync', 'accessSync', + 'readFile', 'writeFile', 'mkdir', 'mkdtemp', 'stat', 'access', + 'cpSync', 'rmSync', 'openSync', 'fstatSync', 'realpathSync', + 'join', 'resolve', 'dirname', 'basename', 'relative', 'normalize', + 'homedir', 'tmpdir', +]); +const FS_BARE_NAMES = new Set(['require']); +const LOG_OBJECT_NAMES = new Set(['console']); +const LOG_METHOD_NAMES = new Set(['log', 'warn', 'error', 'info', 'debug', 'trace']); + +/** + * Returns true if any quasis in the TemplateLiteral contains a content marker. + */ +function isContentTemplate(templateLiteralNode) { + const quasis = templateLiteralNode.quasis || []; + for (const quasi of quasis) { + const raw = quasi.value?.raw ?? quasi.value?.cooked ?? ''; + for (const marker of CONTENT_MARKERS) { + if (raw.includes(marker)) return true; + } + } + return false; +} + +/** + * Returns true if `node` (a CallExpression) is a context where template + * literals are real FS paths or diagnostic messages — NOT content. + * + * Checks: + * - fs.method(templateLiteral, ...) + * - path.method(templateLiteral, ...) + * - console.method(...) + * - new Error(...) + * - require(...) + */ +function isInSuppressedCallContext(expressionNode) { + const parent = expressionNode.parent; + if (!parent) return false; + + // Direct argument to a call expression + if (parent.type === 'CallExpression') { + const callee = parent.callee; + + // fs.*, path.*, os.* calls → FS paths + if ( + callee.type === 'MemberExpression' && + !callee.computed && + callee.object.type === 'Identifier' && + callee.property.type === 'Identifier' && + FS_OBJECT_NAMES.has(callee.object.name) && + FS_METHOD_NAMES.has(callee.property.name) + ) { + return true; + } + + // console.log/warn/error → diagnostic + if ( + callee.type === 'MemberExpression' && + !callee.computed && + callee.object.type === 'Identifier' && + callee.property.type === 'Identifier' && + LOG_OBJECT_NAMES.has(callee.object.name) && + LOG_METHOD_NAMES.has(callee.property.name) + ) { + return true; + } + + // require(...) → not content + if (callee.type === 'Identifier' && FS_BARE_NAMES.has(callee.name)) { + return true; + } + + // W2: bare Error(...) / TypeError(...) / RangeError(...) etc. → diagnostic. + // Handles the call-expression form (as opposed to `new Error(...)` which is + // a NewExpression). Any Identifier callee whose name ends in 'Error' is + // treated as a diagnostic constructor, not content production. + if (callee.type === 'Identifier' && callee.name.endsWith('Error')) { + return true; + } + } + + // new Error(...) → diagnostic + if (parent.type === 'NewExpression') { + const callee = parent.callee; + if (callee.type === 'Identifier' && callee.name.endsWith('Error')) { + return true; + } + } + + // throw statement containing the template → diagnostic + if (parent.type === 'ThrowStatement') { + return true; + } + + return false; +} + +/** + * Returns true if `expressionNode` (the expression inside `${ }`) is a + * content-path-returning call (PATH_RETURNING_FNS minus path.basename) that + * is NOT POSIX-normalized. + * + * Checks: + * 1. If it is a POSIX normalizer call → NOT a violation. + * 2. Unwrap String() cast → check if inner is a content path call. + * 3. Direct content path-returning call. + * + * Returns false if the expression has been POSIX-normalized. + */ +function isUnnormalizedPathExpression(exprNode) { + if (!exprNode) return false; + + // If it's already POSIX-normalized → not a violation + if (isPosixNormalizerCall(exprNode)) return false; + + // Unwrap String() cast + const inner = unwrapString(exprNode); + + // If the unwrapped inner is POSIX-normalized → not a violation + if (isPosixNormalizerCall(inner)) return false; + + // Direct content path-returning call (possibly wrapped in String()) + // Note: path.basename is excluded from CONTENT_PATH_FNS — it returns a + // filename with no directory separators, so it cannot leak backslashes. + if (isContentPathReturningCall(inner)) return true; + + return false; +} + +/** @type {import('eslint').Rule.RuleModule} */ +const rule = { + meta: { + type: 'problem', + docs: { + description: + 'Disallow path-returning calls interpolated into content (markdown/workflow) template ' + + 'literals without POSIX normalization (DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT)', + category: 'Portability', + }, + schema: [], + messages: { + pathInContent: + 'Path-returning call interpolated into content template literal without POSIX normalization ' + + '(RULESET.CONTENT-PATH-NORMALIZATION). ' + + "Normalize at source: String().replace(/\\\\\\\\/g, '/') before interpolating into content. " + + 'See DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT in CONTEXT.md.', + }, + }, + + create(context) { + return { + /** + * Check TemplateLiteral expressions: `...${}...` + * + * For each expression inside the template, if: + * 1. The template contains a content marker in its static parts + * 2. The expression is a path-returning call without POSIX normalization + * 3. The template is NOT in a suppressed call context (fs.*, console.*, Error) + * → report a violation. + */ + TemplateLiteral(node) { + // Check if the entire template is in a suppressed context + if (isInSuppressedCallContext(node)) return; + + const quasis = node.quasis || []; + const exprs = node.expressions || []; + + // Shape (a): any quasi contains a CONTENT_MARKERS marker → check all + // expressions in this template for unnormalized path calls. + if (isContentTemplate(node)) { + for (const expr of exprs) { + if (isUnnormalizedPathExpression(expr)) { + context.report({ node: expr, messageId: 'pathInContent' }); + } + } + return; + } + + // Shape (b): per-expression quasi check. For expression at index i, + // quasis[i+1].raw starts with a forward slash followed by a path that + // terminates in .md or .json — the expression is a path prefix being + // interpolated directly before a content file reference. This catches + // `${computePathPrefix(t)}/commands/gsd/x.md` and + // `@${getGlobalConfigDir('claude')}/commands/gsd/help.md` without + // requiring config-dir markers in CONTENT_MARKERS. + for (let i = 0; i < exprs.length; i++) { + const nextQuasi = quasis[i + 1]; + if (!nextQuasi) continue; + const raw = nextQuasi.value?.raw ?? nextQuasi.value?.cooked ?? ''; + if (QUASI_MD_JSON_RE.test(raw) && isUnnormalizedPathExpression(exprs[i])) { + context.report({ node: exprs[i], messageId: 'pathInContent' }); + } + } + }, + + /** + * Check BinaryExpression string concatenation: + "/foo.md" + * + * For `left + right` or `right + left` where one side is a string + * literal containing a content marker and the other is a path-returning + * call without POSIX normalization. + * + * W3 (right-deep FN): when a content marker is present anywhere in the + * concat tree, search the ENTIRE tree recursively for any unnormalized + * path-returning call — not just the immediate sibling. This catches + * '@~/' + (name + path.join(home, '.claude')) + * where the path call is nested inside a right-side BinaryExpression. + */ + BinaryExpression(node) { + if (node.operator !== '+') return; + + const { left, right } = node; + + // isContentString: recursively check if a node (or its concat + // sub-tree) contains a Literal/TemplateLiteral quasi with a + // content marker. Descends into nested BinaryExpression `+` chains. + function isContentString(n) { + if (!n) return false; + // Plain string literal + if (n.type === 'Literal' && typeof n.value === 'string') { + return CONTENT_MARKERS.some((m) => n.value.includes(m)); + } + // TemplateLiteral — check quasis (static parts) + if (n.type === 'TemplateLiteral') { + for (const quasi of (n.quasis || [])) { + const raw = quasi.value?.raw ?? quasi.value?.cooked ?? ''; + if (CONTENT_MARKERS.some((m) => raw.includes(m))) return true; + } + } + // Descend into nested + concatenations + if (n.type === 'BinaryExpression' && n.operator === '+') { + return isContentString(n.left) || isContentString(n.right); + } + return false; + } + + const treeHasContent = isContentString(left) || isContentString(right); + + // Suppress if the whole concatenation is in a suppressed context + if (isInSuppressedCallContext(node)) return; + + if (!treeHasContent) return; + + // Only report at the TOP-LEVEL BinaryExpression for this concat chain + // (i.e. when the parent is NOT also a `+` BinaryExpression) to avoid + // duplicate reports on every node of a chained concatenation. + const parentNode = node.parent; + if ( + parentNode && + parentNode.type === 'BinaryExpression' && + parentNode.operator === '+' + ) { + return; + } + + // Recursively scan the full concat tree for unnormalized path calls + // and report each one found. + function scanAndReport(n) { + if (!n) return; + if (n.type === 'BinaryExpression' && n.operator === '+') { + // Check left + if (isUnnormalizedPathExpression(n.left)) { + context.report({ node: n.left, messageId: 'pathInContent' }); + } else { + scanAndReport(n.left); + } + // Check right + if (isUnnormalizedPathExpression(n.right)) { + context.report({ node: n.right, messageId: 'pathInContent' }); + } else { + scanAndReport(n.right); + } + } + } + + scanAndReport(node); + }, + }; + }, +}; + +module.exports = rule; diff --git a/eslint-rules/require-fs-op-fallback.cjs b/eslint-rules/require-fs-op-fallback.cjs new file mode 100644 index 000000000..2dc68e989 --- /dev/null +++ b/eslint-rules/require-fs-op-fallback.cjs @@ -0,0 +1,336 @@ +'use strict'; + +/** + * require-fs-op-fallback + * + * Flag: a bare fs.rename / fs.renameSync call (the atomic-publish primitive + * named first in DEFECT.WINDOWS-FS-OPS.symptom) that is NOT either: + * + * (a) inside a try/catch whose catch handler BOTH references a transient + * errno ('EPERM' / 'EBUSY' / 'EACCES', literally OR via a *RETRY_ERRNOS- + * style set identifier) AND carries a retry signal (a loop `continue` + * backedge or a `return ` delegation — NOT a bare rethrow: the + * defect's cure is retry/fallback, not just errno recognition), OR + * (b) control-dependent on a Windows platform guard + * (process.platform !== 'win32' / early-return — isWindowsExcludedNode). + * + * The canonical defect: on Windows, when an antivirus scanner, indexer, or + * concurrent reader transiently holds the target open, fs.renameSync throws + * EPERM/EBUSY/EACCES. A bare renameSync (or one wrapped in a try/catch that + * only cleans up + rethrows without distinguishing the transient errno) fails + * on the windows-latest CI lane where macOS/Linux CI passed — the established + * cure is the RENAME_RETRY_ERRNOS = new Set(['EPERM','EBUSY','EACCES']) retry + * loop already present in five production modules. + * + * "never silently swallow": a catch (e) {} or catch (_) {} with no transient- + * errno reference does NOT satisfy the defect's fix-forward and is still + * flagged. The fix is to add the bounded retry (the RENAME_RETRY_ERRNOS + * pattern) or gate behind a Windows platform check. + * + * copyFile / unlink are deliberately NOT flagged: per the defect's own + * .fix-forward ("catch EPERM/EBUSY/EACCES, fall back to copy + unlink with + * retry") they are the FALLBACK PRIMITIVES, not separate defect sites, and + * unlink has many intentional best-effort try/catch-swallow cleanup sites. + * + * References: + * DEFECT.WINDOWS-FS-OPS (CONTEXT.md) + * ADR-1703 (docs/adr/1703-portability-enforcement-architecture.md) + * issue #1740 (scope note: rename-only v1) + * + * Message: + * Cite DEFECT.WINDOWS-FS-OPS: fs.renameSync can throw EPERM/EBUSY/EACCES on + * Windows when a reader/AV transiently holds the target. Wrap in a bounded + * retry on the transient errno (the RENAME_RETRY_ERRNOS pattern) or gate + * behind a Windows platform check. + * + * ── Known boundaries ───────────────────────────────────────────────────────── + * + * (a) Name-based matching only. The rule recognizes `fs.rename` / `fs.renameSync` + * by spelling (MemberExpression: object=Identifier{fs}). A bare + * `renameSync(...)` call (when `fs` is destructured or the function is + * imported bare) is NOT matched — the production survey showed 100% + * `fs.renameSync` dotted usage, so dotted-only is the v1 shape. + * + * (b) Retry delegated to a helper function is NOT statically traceable. A + * bare `fs.renameSync` inside `atomicRenameWithRetry` IS detected as + * compliant because that helper wraps it in its own try/catch with the + * RENAME_RETRY_ERRNOS reference — but a call site that delegates via + * `atomicRenameWithRetry(tmp, target)` (calling the helper, no bare + * renameSync at the call site) has nothing to flag in the first place. + * + * (c) The catch-handler errno check is a subtree scan for transient-errno + * string literals OR *RETRY_ERRNOS identifiers. A catch that builds the + * errno set from a non-literal source (e.g. reading from config) is not + * recognized — the established convention is a module-level Set literal. + */ + +const { isWindowsExcludedNode } = require('./lib/platform-guard.cjs'); + +// fs mutation methods that are the atomic-publish transient-lock primitives. +const RENAME_METHODS = new Set(['rename', 'renameSync']); + +// Transient Windows lock errnos (the DEFECT.WINDOWS-FS-OPS.fix-forward set). +const TRANSIENT_ERRNOS = new Set(['EPERM', 'EBUSY', 'EACCES']); + +// Recognize retry-errno set identifiers by naming convention, e.g. +// RENAME_RETRY_ERRNOS, WRITE_RETRY_ERRNOS. Matches the established pattern +// across capability-ledger / capability-consent / shell-command-projection. +const RETRY_ERRNO_SET_NAME_RE = /RETRY_ERRNOS$/; + +/** + * True if `node` is an `fs.rename` / `fs.renameSync` CallExpression. + */ +function isFsRenameCall(node) { + if (!node || node.type !== 'CallExpression') return false; + const callee = node.callee; + if ( + callee.type === 'MemberExpression' && + !callee.computed && + callee.object.type === 'Identifier' && + callee.object.name === 'fs' && + callee.property.type === 'Identifier' && + RENAME_METHODS.has(callee.property.name) + ) { + return true; + } + return false; +} + +/** + * Walk a catch-clause subtree looking for evidence the handler distinguishes + * a transient errno. Recognized evidence: + * - a string Literal whose value is in TRANSIENT_ERRNOS ('EPERM'/'EBUSY'/'EACCES') + * - an Identifier (or MemberExpression object) whose name matches RETRY_ERRNO_SET_NAME_RE + * + * Skips `parent`/`tokens`/`comments` keys to avoid cycles. + */ +function catchHandlerReferencesTransientErrno(handlerNode) { + if (!handlerNode || typeof handlerNode !== 'object') return false; + // The CatchClause node has { type, param, body, parent }. Inspect body + // (and param name — not needed, but walk body subtree). + const seen = new WeakSet(); + function walk(n) { + if (!n || typeof n !== 'object') return false; + if (seen.has(n)) return false; + seen.add(n); + + // String literal errno: 'EPERM' / 'EBUSY' / 'EACCES' + if (n.type === 'Literal' && typeof n.value === 'string' && TRANSIENT_ERRNOS.has(n.value)) { + return true; + } + // *RETRY_ERRNOS identifier (bare or as a MemberExpression object) + if (n.type === 'Identifier' && RETRY_ERRNO_SET_NAME_RE.test(n.name)) { + return true; + } + + for (const key of Object.keys(n)) { + if (key === 'parent' || key === 'tokens' || key === 'comments') continue; + const child = n[key]; + if (Array.isArray(child)) { + for (const item of child) { + if (item && typeof item === 'object' && item.type) { + if (walk(item)) return true; + } + } + } else if (child && typeof child === 'object' && child.type) { + if (walk(child)) return true; + } + } + return false; + } + return walk(handlerNode); +} + +/** + * True when `handlerNode` (a CatchClause) contains a RETRY SIGNAL — evidence the + * catch actually re-attempts the rename rather than merely observing the errno. + * + * Recognized retry signals: + * - ContinueStatement — a loop backedge (`for { try{rename}catch{continue} }`) + * - ReturnStatement whose argument is a CallExpression — delegation + * (`return retry()`, `return atomicRenameWithRetry(...)`) + * + * This closes the "errno-check-then-rethrow" false-negative: a catch like + * `catch (e) { if (e.code === 'EPERM') throw e; throw e; }` references the + * errno but never retries, so it still fails on Windows transient locks. The + * DEFECT.WINDOWS-FS-OPS fix-forward requires retry/fallback, not just recognition. + * + * Skips `parent`/`tokens`/`comments` keys to avoid cycles. + */ +function catchHandlerHasRetrySignal(handlerNode) { + if (!handlerNode || typeof handlerNode !== 'object') return false; + const seen = new WeakSet(); + function walk(n) { + if (!n || typeof n !== 'object') return false; + if (seen.has(n)) return false; + seen.add(n); + // Loop backedge: `continue` re-enters the enclosing retry loop. + if (n.type === 'ContinueStatement') return true; + // Delegation: `return retry()` / `return atomicRenameWithRetry(...)` hands + // the rename off to a helper that performs its own bounded retry. + if ( + n.type === 'ReturnStatement' && + n.argument != null && + n.argument.type === 'CallExpression' + ) { + return true; + } + for (const key of Object.keys(n)) { + if (key === 'parent' || key === 'tokens' || key === 'comments') continue; + const child = n[key]; + if (Array.isArray(child)) { + for (const item of child) { + if (item && typeof item === 'object' && item.type) { + if (walk(item)) return true; + } + } + } else if (child && typeof child === 'object' && child.type) { + if (walk(child)) return true; + } + } + return false; + } + return walk(handlerNode); +} + +/** + * True when `renameNode` is protected by a transient-errno retry: i.e. the + * NEAREST enclosing TryStatement WITH A CATCH HANDLER whose `block` contains + * the rename has a handler that BOTH references a transient errno AND carries a + * retry signal (loop backedge or delegation return). + * + * Walks bottom-up and STOPS at the first TryStatement that (a) contains the + * rename in its `block` and (b) has a `handler`. A try with only a `finally` + * (no handler) does not intercept the rename error — it is skipped and the + * climb continues. The nearest catching try is where the rename's error lands; + * an outer catch is UNREACHABLE once the nearest catch intercepts (it may + * swallow, transform, or rethrow-as-other), so walking past it would be + * unsound (a false negative — see the nested-try case). An errno reference + * alone is insufficient; the handler must also retry (see catchHandlerHasRetrySignal). + */ +function isInsideTransientErrnoTryCatch(renameNode, sourceCode) { + const ancestors = _getAncestors(renameNode, sourceCode); + for (let i = ancestors.length - 1; i >= 0; i--) { + const anc = ancestors[i]; + if (anc.type !== 'TryStatement') continue; + if (!_containsNode(anc.block, renameNode)) continue; + if (!anc.handler) continue; // try-finally: error propagates, keep climbing + // Nearest catching try found — its handler is authoritative. An outer + // catch cannot protect the rename if this one intercepts first. + return ( + catchHandlerReferencesTransientErrno(anc.handler) && + catchHandlerHasRetrySignal(anc.handler) + ); + } + return false; +} + +// ── AST traversal helpers (mirror platform-guard.cjs internals) ────────────── + +function _getAncestors(node, sourceCode) { + if (sourceCode && typeof sourceCode.getAncestors === 'function') { + try { + return sourceCode.getAncestors(node); + } catch (_) { + // fall through to manual walk + } + } + return _findAncestors(sourceCode.ast, node); +} + +function _findAncestors(root, target) { + const chain = []; + function walk(node, ancestors) { + if (!node || typeof node !== 'object') return false; + if (node === target) { + chain.push(...ancestors); + return true; + } + for (const key of Object.keys(node)) { + if (key === 'parent' || key === 'tokens' || key === 'comments') continue; + const child = node[key]; + if (Array.isArray(child)) { + for (const item of child) { + if (item && typeof item === 'object' && item.type) { + if (walk(item, [...ancestors, node])) return true; + } + } + } else if (child && typeof child === 'object' && child.type) { + if (walk(child, [...ancestors, node])) return true; + } + } + return false; + } + walk(root, []); + return chain; +} + +function _containsNode(container, target) { + if (!container || typeof container !== 'object') return false; + if (container === target) return true; + const seen = new WeakSet(); + function walk(n) { + if (!n || typeof n !== 'object') return false; + if (seen.has(n)) return false; + seen.add(n); + if (n === target) return true; + for (const key of Object.keys(n)) { + if (key === 'parent' || key === 'tokens' || key === 'comments') continue; + const child = n[key]; + if (Array.isArray(child)) { + for (const item of child) { + if (item && typeof item === 'object' && item.type) { + if (walk(item)) return true; + } + } + } else if (child && typeof child === 'object' && child.type) { + if (walk(child)) return true; + } + } + return false; + } + return walk(container); +} + +/** @type {import('eslint').Rule.RuleModule} */ +const rule = { + meta: { + type: 'problem', + docs: { + description: + 'Require fs.rename/fs.renameSync to carry a transient-errno fallback (EPERM/EBUSY/EACCES) ' + + 'or a Windows platform guard (DEFECT.WINDOWS-FS-OPS)', + category: 'Portability', + }, + schema: [], + messages: { + requireFsOpFallback: + 'Unguarded fs.rename/fs.renameSync: on Windows a concurrent reader or antivirus scanner ' + + 'can transiently hold the target open, throwing EPERM/EBUSY/EACCES ' + + '(DEFECT.WINDOWS-FS-OPS). Wrap in a bounded retry on the transient errno ' + + "(the RENAME_RETRY_ERRNOS = new Set(['EPERM','EBUSY','EACCES']) pattern) " + + "or gate behind if (process.platform !== 'win32').", + }, + }, + + create(context) { + const sourceCode = context.sourceCode ?? context.getSourceCode(); + + return { + CallExpression(node) { + if (!isFsRenameCall(node)) return; + + // (a) inside a try/catch whose catch handles a transient errno + if (isInsideTransientErrnoTryCatch(node, sourceCode)) return; + + // (b) control-dependent on a Windows platform guard + if (isWindowsExcludedNode(node, sourceCode)) return; + + // Otherwise: unguarded atomic-publish rename — report. + context.report({ node, messageId: 'requireFsOpFallback' }); + }, + }; + }, +}; + +module.exports = rule; diff --git a/eslint-rules/require-userprofile-with-home.cjs b/eslint-rules/require-userprofile-with-home.cjs new file mode 100644 index 000000000..0852daaf2 --- /dev/null +++ b/eslint-rules/require-userprofile-with-home.cjs @@ -0,0 +1,114 @@ +'use strict'; + +/** + * require-userprofile-with-home + * + * Flag test files that assign `process.env.HOME` without also referencing + * `USERPROFILE` anywhere in the file. + * + * ## What this enforces (G6) + * + * Program-level: collect assignments to `process.env.HOME` + * (`process.env.HOME = …` / `process.env['HOME'] = …`); track whether + * `USERPROFILE` appears anywhere in the file (any reference). At + * `Program:exit`, if HOME is assigned and `USERPROFILE` never appears, report + * each HOME assignment. + * + * Message: Windows uses `USERPROFILE`, not `HOME` — set + * `process.env.USERPROFILE` alongside. + * + * DEFECT category: DEFECT.WINDOWS-TEST-PORTABILITY + */ + +/** @type {import('eslint').Rule.RuleModule} */ +const rule = { + meta: { + type: 'problem', + docs: { + description: + 'Require process.env.USERPROFILE to be set alongside process.env.HOME (Windows portability)', + category: 'Portability', + }, + schema: [], + messages: { + missingUserProfile: + 'Assigning process.env.HOME without process.env.USERPROFILE is not portable ' + + '(DEFECT.WINDOWS-TEST-PORTABILITY): Windows uses USERPROFILE as the home ' + + 'directory environment variable, not HOME. Set process.env.USERPROFILE ' + + 'alongside process.env.HOME.', + }, + }, + + create(context) { + const sourceCode = context.sourceCode ?? context.getSourceCode(); + + /** Collected HOME assignment nodes */ + const homeAssignments = []; + + /** Whether a real process.env.USERPROFILE = … assignment exists in the file */ + let userProfileAssigned = false; + + /** + * Returns true if node is an assignment to process.env[key] or + * process.env.key for the given key name. + * + * Recognized shapes (as the left-hand side of AssignmentExpression): + * process.env.KEY — MemberExpression(MemberExpression, Identifier) + * process.env['KEY'] — MemberExpression(MemberExpression, Literal, computed=true) + * + * @param {import('eslint').Rule.Node} lhs — left side of AssignmentExpression + * @param {string} key — the env var name to check + * @returns {boolean} + */ + function isProcessEnvAssignment(lhs, key) { + if (!lhs || lhs.type !== 'MemberExpression') return false; + const obj = lhs.object; + if (!obj || obj.type !== 'MemberExpression') return false; + + // obj must be process.env + if ( + obj.computed || + obj.object.type !== 'Identifier' || + obj.object.name !== 'process' || + obj.property.type !== 'Identifier' || + obj.property.name !== 'env' + ) { + return false; + } + + // Property must be key (identifier or string literal) + if (!lhs.computed) { + return lhs.property.type === 'Identifier' && lhs.property.name === key; + } else { + return ( + lhs.property.type === 'Literal' && lhs.property.value === key + ); + } + } + + return { + AssignmentExpression(node) { + if (isProcessEnvAssignment(node.left, 'HOME')) { + homeAssignments.push(node); + } + // Track actual USERPROFILE assignments (not mere text/comment mentions) + if (isProcessEnvAssignment(node.left, 'USERPROFILE')) { + userProfileAssigned = true; + } + }, + + 'Program:exit'() { + if (homeAssignments.length === 0) return; + + // Only suppress if USERPROFILE is actually ASSIGNED (not just mentioned in a comment) + if (userProfileAssigned) return; + + for (const node of homeAssignments) { + context.report({ node, messageId: 'missingUserProfile' }); + } + }, + }; + }, +}; + +module.exports = rule; diff --git a/eslint.config.mjs b/eslint.config.mjs index 20ec0157b..f0b10f905 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -15,6 +15,15 @@ import noElapsedAssertion from './eslint-rules/no-elapsed-assertion.cjs'; import noRawRmsyncInTests from './eslint-rules/no-raw-rmsync-in-tests.cjs'; import noTautologicalAssert from './eslint-rules/no-tautological-assert.cjs'; import noAdhocMarkdownParsing from './eslint-rules/no-adhoc-markdown-parsing.cjs'; +import noPathLiteralInAssert from './eslint-rules/no-path-literal-in-assert.cjs'; +import noPosixModeBitAssert from './eslint-rules/no-posix-mode-bit-assert.cjs'; +import noUnguardedNonportableExec from './eslint-rules/no-unguarded-nonportable-exec.cjs'; +import noCrlfFragileSplit from './eslint-rules/no-crlf-fragile-split.cjs'; +import noHardcodedTmp from './eslint-rules/no-hardcoded-tmp.cjs'; +import noBareNpmExec from './eslint-rules/no-bare-npm-exec.cjs'; +import requireUserprofileWithHome from './eslint-rules/require-userprofile-with-home.cjs'; +import normalizePathInContent from './eslint-rules/normalize-path-in-content.cjs'; +import requireFsOpFallback from './eslint-rules/require-fs-op-fallback.cjs'; const localPlugin = { rules: { @@ -24,6 +33,15 @@ const localPlugin = { 'no-raw-rmsync-in-tests': noRawRmsyncInTests, 'no-tautological-assert': noTautologicalAssert, 'no-adhoc-markdown-parsing': noAdhocMarkdownParsing, + 'no-path-literal-in-assert': noPathLiteralInAssert, + 'no-posix-mode-bit-assert': noPosixModeBitAssert, + 'no-unguarded-nonportable-exec': noUnguardedNonportableExec, + 'no-crlf-fragile-split': noCrlfFragileSplit, + 'no-hardcoded-tmp': noHardcodedTmp, + 'no-bare-npm-exec': noBareNpmExec, + 'require-userprofile-with-home': requireUserprofileWithHome, + 'normalize-path-in-content': normalizePathInContent, + 'require-fs-op-fallback': requireFsOpFallback, }, }; @@ -39,6 +57,8 @@ export default tseslint.config( '**/*.generated.cjs', // ADR-457: tsc-generated runtime artifact — lint the src/*.cts source, not the emitted .cjs. 'gsd-core/bin/lib/semver-compare.cjs', + 'gsd-core/bin/lib/host-integration.cjs', + 'gsd-core/bin/lib/install-engine.cjs', 'gsd-core/bin/lib/capability-loader.cjs', 'gsd-core/bin/lib/capability-source.cjs', 'gsd-core/bin/lib/capability-ledger.cjs', @@ -172,6 +192,8 @@ export default tseslint.config( 'gsd-core/bin/lib/git-base-branch.cjs', // ADR-1213: tsc-generated runtime artifact — lint the src/capability-writer.cts source. 'gsd-core/bin/lib/capability-writer.cjs', + // issue #1754: tsc-generated runtime artifact — lint the src/cli-skew-check.cts source. + 'gsd-core/bin/lib/cli-skew-check.cjs', // issue #1355: tsc-generated runtime artifact — lint the src/teams-status.cts source. 'gsd-core/bin/lib/teams-status.cjs', // ADR-1372: tsc-generated runtime artifact — lint the src/markdown-sectionizer.cts source. @@ -200,6 +222,42 @@ export default tseslint.config( // ADR-1372 T7: enforce use of the markdown-sectionizer seam; grandfather // pre-migration sites with // allow-adhoc-markdown: 'local/no-adhoc-markdown-parsing': 'error', + // ADR-1703 Phase 5: flag path-returning calls interpolated into content + // (markdown @-references, workflow files, generated docs) without POSIX + // normalization. Promoted to 'error' after precision review (path.basename + // excluded; content heuristic tightened to genuine reference/config-dir + // markers). See RULESET.CONTENT-PATH-NORMALIZATION in CONTEXT.md. + 'local/normalize-path-in-content': 'error', + // ADR-1703 Phase 6: flag an unguarded fs.rename/fs.renameSync (the + // atomic-publish primitive) that lacks a transient-errno fallback + // (EPERM/EBUSY/EACCES retry or a Windows platform guard). See + // DEFECT.WINDOWS-FS-OPS in CONTEXT.md. + 'local/require-fs-op-fallback': 'error', + }, + }, + + // ── bin/install.js + scripts/build-hooks.js — ADR-1703 Phase 6 glob expansion ─ + // The top-level `bin/install.js` (generated installer) and `scripts/build-hooks.js` + // (the build-side atomic-replace helper) are the two production surfaces named by + // DEFECT.WINDOWS-FS-OPS that were NOT covered by the src/**/*.cts / gsd-core/bin/**/*.cjs + // globs (ADR-1703 L124-126). This block brings them under the two production + // portability rules. It deliberately does NOT apply the full js.recommended set — + // bin/install.js is ~12k lines of generated code; the ADR's mandate is the + // portability defect surface, not a broader generated-code style sweep. + { + files: ['bin/install.js', 'scripts/build-hooks.js'], + plugins: { + local: localPlugin, + }, + languageOptions: { + sourceType: 'commonjs', + globals: { + ...globals.node, + }, + }, + rules: { + 'local/normalize-path-in-content': 'error', + 'local/require-fs-op-fallback': 'error', }, }, @@ -264,6 +322,20 @@ export default tseslint.config( 'local/no-tautological-assert': 'error', // Ban source-grep pattern in tests — use require() + behavior assertions instead 'local/no-source-grep': 'error', + // Ban path-returning calls compared to hardcoded POSIX-slash literals (fails on Windows) + 'local/no-path-literal-in-assert': 'error', + // Ban POSIX mode-bit assertions compared to octal literals (fails on Windows) + 'local/no-posix-mode-bit-assert': 'error', + // Ban unguarded chmod exec-bit + sh/bash -c combos (fails on Windows Git Bash) + 'local/no-unguarded-nonportable-exec': 'error', + // Ban CRLF-fragile file-content splits and regex patterns (ADR-1703 Phase 4) + 'local/no-crlf-fragile-split': 'error', + // Ban hardcoded /tmp/ paths in fs.* calls (ADR-1703 Phase 4) + 'local/no-hardcoded-tmp': 'error', + // Ban bare npm exec without shell:true (ADR-1703 Phase 4) + 'local/no-bare-npm-exec': 'error', + // Require USERPROFILE alongside HOME assignments (ADR-1703 Phase 4) + 'local/require-userprofile-with-home': 'error', // Ban raw setTimeout sync + elapsed/duration-style assertions via no-restricted-syntax 'no-restricted-syntax': [ 'error', diff --git a/examples/dynamic-context-management/CONTEXT-INDEX.json b/examples/dynamic-context-management/CONTEXT-INDEX.json index 82028610d..674caf541 100644 --- a/examples/dynamic-context-management/CONTEXT-INDEX.json +++ b/examples/dynamic-context-management/CONTEXT-INDEX.json @@ -919,7 +919,7 @@ { "id": "DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.prevention", "klass": "DEFECT", - "value": "ref DEFECT.WINDOWS-TEST-PORTABILITY — gsd-test is Mac/Linux only (no Windows host), only the CI windows-latest lane catches this; run npm run lint:ci (lint-windows-test-portability) before push; prefer asserting the BEHAVIOR (command shape, runnability) over the filesystem mode bit", + "value": "ref DEFECT.WINDOWS-TEST-PORTABILITY — gsd-test is Mac/Linux only (no Windows host), only the CI windows-latest lane catches this; run npm run lint:ci (local/no-unguarded-nonportable-exec now enforces this via ESLint) before push; prefer asserting the BEHAVIOR (command shape, runnability) over the filesystem mode bit", "line": 735 }, { @@ -931,7 +931,7 @@ { "id": "DEFECT.WINDOWS-TEST-PORTABILITY.detect", "klass": "DEFECT", - "value": "npm run lint:windows-test-portability (tripwire: flags tests combining chmod exec-bit with sh/bash -c and no platform guard); watch CI windows matrix green before declaring a PR done", + "value": "local/no-unguarded-nonportable-exec ESLint rule (enforced in tests/**/*.test.cjs via npm run lint); flags tests combining chmod exec-bit with sh/bash -c and no platform guard; watch CI windows matrix green before declaring a PR done", "line": 727 }, { @@ -943,7 +943,7 @@ { "id": "DEFECT.WINDOWS-TEST-PORTABILITY.fix-forward", "klass": "DEFECT", - "value": "gate platform-specific execution with if (process.platform !== 'win32'); normalize path expectations to forward slashes with .replace(/\\\\/g, '/'); invoke scripts via explicit interpreter (sh ) rather than relying on exec-bit; annotate // windows-portability-ok: when a bypass is intentional", + "value": "gate platform-specific execution with if (process.platform !== 'win32') — there is no opt-out annotation; structure any platform-specific code behind this guard; normalize path expectations to forward slashes with .replace(/\\\\/g, '/'); invoke scripts via explicit interpreter (sh ) rather than relying on exec-bit; see local/no-unguarded-nonportable-exec ESLint rule and docs/how-to/windows-portability.md for details", "line": 728 }, { diff --git a/gsd-core/bin/gsd-tools.cjs b/gsd-core/bin/gsd-tools.cjs index 4c9548a48..d3428860e 100755 --- a/gsd-core/bin/gsd-tools.cjs +++ b/gsd-core/bin/gsd-tools.cjs @@ -204,6 +204,24 @@ const projectRoot = require('./lib/project-root.cjs'); // against any require/load-ordering edge where the export isn't bound yet // when this entrypoint is first required (#604). const findProjectRoot = (...args) => projectRoot.findProjectRoot(...args); + +// #1754: CLI skew detection — warn (stderr, non-blocking) if this gsd-tools.cjs +// is NOT the project-local install while a project-local install exists. Catches +// the shadowing scenario from #1748 (stale global canary shadowing project-local). +try { + const _skew = require('./lib/cli-skew-check.cjs'); + const _skewRoot = findProjectRoot(process.cwd()); + if (_skewRoot) { + const _skewLocal = path.join(_skewRoot, '.claude', 'gsd-core', 'bin', 'gsd-tools.cjs'); + const _skewWarn = _skew.checkCliSkew({ + resolvedPath: path.resolve(__filename), + projectRoot: _skewRoot, + projectLocalExists: fs.existsSync(_skewLocal), + }); + if (_skewWarn) process.stderr.write(_skewWarn + '\n'); + } +} catch { /* advisory — never block */ } + const { getActiveWorkstream } = require('./lib/planning-workspace.cjs'); const { resolveActiveWorkstream, applyResolvedWorkstreamEnv } = require('./lib/active-workstream-store.cjs'); const state = require('./lib/state.cjs'); @@ -1238,6 +1256,56 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand break; } + case 'dispatch-should-flatten': { + // #1708 / #853: typed query replacing the `RUNTIME === 'codex'` prose rule. + // + // Resolves the current runtime (GSD_RUNTIME > config.runtime > 'claude'), + // looks up registry.runtimes[id].runtime.hostIntegration.dispatch, and + // calls shouldFlattenDispatch(dispatch) from host-integration.cjs. + // + // Fail-closed: any unknown runtime, missing dispatch, or thrown error + // yields `true` (inline — the always-safe default). + // + // Output: + // --raw → prints exactly `true` or `false` + // --json → prints { runtime, shouldFlatten, dispatch } + // default → same as --raw + try { + // Resolve runtime using the same precedence as `config-get runtime`. + const { resolveRuntime } = require('./lib/runtime-slash.cjs'); + const runtimeId = resolveRuntime(cwd); + + // Look up dispatch from the capability registry. + const registry = require('./lib/capability-registry.cjs'); + const runtimeEntry = registry.runtimes != null + ? registry.runtimes[runtimeId] + : null; + const dispatch = runtimeEntry?.runtime?.hostIntegration?.dispatch ?? null; + + // Call shouldFlattenDispatch from host-integration.cjs. + const hostIntegration = require('./lib/host-integration.cjs'); + const shouldFlat = dispatch !== null + ? hostIntegration.shouldFlattenDispatch(dispatch) + : true; // fail-closed: unknown runtime → inline + + const jsonIdx = args.indexOf('--json'); + if (jsonIdx !== -1) { + output({ + runtime: runtimeId, + shouldFlatten: shouldFlat, + dispatch: dispatch, + }, raw); + } else { + // --raw or default: print exactly true or false + process.stdout.write(shouldFlat ? 'true' : 'false'); + } + } catch { + // Fail-closed on any error: inline is always safe. + process.stdout.write('true'); + } + break; + } + case 'config-new-project': { // Phase 6 (#3575): dispatch via SDK executeForCjs when available. const handled = _dispatchNonFamily({ diff --git a/gsd-core/bin/lib/capability-registry.cjs b/gsd-core/bin/lib/capability-registry.cjs index 79de051b7..4d1b0c64f 100644 --- a/gsd-core/bin/lib/capability-registry.cjs +++ b/gsd-core/bin/lib/capability-registry.cjs @@ -86,6 +86,7 @@ const capabilities = { ], "probeExists": "gsd-core/VERSION" }, + "localConfigDir": ".agents", "configFormat": "settings-json", "artifactLayout": { "global": [ @@ -117,7 +118,24 @@ const capabilities = { "installSurface": "settings-json", "writesSharedSettings": true, "permissionWriter": null, - "extendedHookEvents": [] + "extendedHookEvents": [], + "hostIntegration": { + "embeddingMode": "declarative", + "commandSurface": "slash-file", + "dispatch": { + "namedDispatch": "undocumented", + "nested": "undocumented", + "maxDepth": "undocumented", + "background": true, + "subagentToolkit": "undocumented", + "backgroundDispatch": "undocumented" + }, + "modelMode": "passive", + "hookBus": "host", + "stateIO": "filesystem", + "transport": "mcp", + "runtime": "go" + } } }, "audit": { @@ -176,6 +194,7 @@ const capabilities = { "AUGMENT_CONFIG_DIR" ] }, + "localConfigDir": ".augment", "configFormat": "settings-json", "artifactLayout": { "global": [ @@ -194,6 +213,14 @@ const capabilities = { "nesting": "nested", "recursive": false, "converter": "convertClaudeCommandToAugmentSkill" + }, + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": "convertClaudeAgentToAugmentAgent" } ], "local": [ @@ -212,6 +239,14 @@ const capabilities = { "nesting": "nested", "recursive": false, "converter": "convertClaudeCommandToAugmentSkill" + }, + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": "convertClaudeAgentToAugmentAgent" } ] }, @@ -223,7 +258,24 @@ const capabilities = { "installSurface": "settings-json", "writesSharedSettings": true, "permissionWriter": null, - "extendedHookEvents": [] + "extendedHookEvents": [], + "hostIntegration": { + "embeddingMode": "declarative", + "commandSurface": "slash-file", + "dispatch": { + "namedDispatch": true, + "nested": "undocumented", + "maxDepth": "undocumented", + "background": true, + "subagentToolkit": "full", + "backgroundDispatch": "undocumented" + }, + "modelMode": "passive", + "hookBus": "host", + "stateIO": "filesystem", + "transport": "mcp", + "runtime": "node" + } } }, "claude": { @@ -245,6 +297,7 @@ const capabilities = { "CLAUDE_CONFIG_DIR" ] }, + "localConfigDir": ".claude", "configFormat": "settings-json", "artifactLayout": { "global": [ @@ -289,7 +342,24 @@ const capabilities = { "Stop", "PreCompact", "FileChanged" - ] + ], + "hostIntegration": { + "embeddingMode": "imperative", + "commandSurface": "slash-file", + "dispatch": { + "namedDispatch": true, + "nested": true, + "maxDepth": 5, + "background": true, + "subagentToolkit": "full", + "backgroundDispatch": false + }, + "modelMode": "passive", + "hookBus": "host", + "stateIO": "filesystem", + "transport": "mcp", + "runtime": "node" + } } }, "cline": { @@ -311,6 +381,7 @@ const capabilities = { "CLINE_CONFIG_DIR" ] }, + "localConfigDir": ".cline", "configFormat": "markdown-dir", "artifactLayout": { "global": [ @@ -332,7 +403,24 @@ const capabilities = { "installSurface": "cline-rules", "writesSharedSettings": false, "permissionWriter": null, - "extendedHookEvents": [] + "extendedHookEvents": [], + "hostIntegration": { + "embeddingMode": "imperative", + "commandSurface": "slash-file", + "dispatch": { + "namedDispatch": true, + "nested": false, + "maxDepth": 1, + "background": true, + "subagentToolkit": "read-only", + "backgroundDispatch": false + }, + "modelMode": "active", + "hookBus": "host", + "stateIO": "filesystem", + "transport": "mcp", + "runtime": "node" + } } }, "code-review": { @@ -415,6 +503,7 @@ const capabilities = { "CODEBUDDY_CONFIG_DIR" ] }, + "localConfigDir": ".codebuddy", "configFormat": "settings-json", "artifactLayout": { "global": [ @@ -433,6 +522,14 @@ const capabilities = { "nesting": "flat", "recursive": false, "converter": "convertClaudeCommandToCodebuddySkill" + }, + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": "convertClaudeAgentToCodebuddyAgent" } ], "local": [ @@ -451,6 +548,14 @@ const capabilities = { "nesting": "flat", "recursive": false, "converter": "convertClaudeCommandToCodebuddySkill" + }, + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": "convertClaudeAgentToCodebuddyAgent" } ] }, @@ -462,7 +567,24 @@ const capabilities = { "installSurface": "settings-json", "writesSharedSettings": true, "permissionWriter": null, - "extendedHookEvents": [] + "extendedHookEvents": [], + "hostIntegration": { + "embeddingMode": "declarative", + "commandSurface": "slash-file", + "dispatch": { + "namedDispatch": true, + "nested": false, + "maxDepth": 1, + "background": true, + "subagentToolkit": "full", + "backgroundDispatch": false + }, + "modelMode": "passive", + "hookBus": "host", + "stateIO": "filesystem", + "transport": "mcp", + "runtime": "node" + } } }, "codex": { @@ -484,6 +606,7 @@ const capabilities = { "CODEX_HOME" ] }, + "localConfigDir": ".codex", "configFormat": "toml", "artifactLayout": { "global": [ @@ -515,7 +638,24 @@ const capabilities = { "installSurface": "codex-toml", "writesSharedSettings": false, "permissionWriter": null, - "extendedHookEvents": [] + "extendedHookEvents": [], + "hostIntegration": { + "embeddingMode": "declarative", + "commandSurface": "slash-file", + "dispatch": { + "namedDispatch": true, + "nested": true, + "maxDepth": 1, + "background": true, + "subagentToolkit": "full", + "backgroundDispatch": true + }, + "modelMode": "passive", + "hookBus": "host", + "stateIO": "filesystem", + "transport": "mcp", + "runtime": "node" + } } }, "copilot": { @@ -538,6 +678,7 @@ const capabilities = { "COPILOT_HOME" ] }, + "localConfigDir": ".github", "configFormat": "markdown", "artifactLayout": { "global": [ @@ -568,7 +709,24 @@ const capabilities = { "installSurface": "copilot-instructions", "writesSharedSettings": false, "permissionWriter": null, - "extendedHookEvents": [] + "extendedHookEvents": [], + "hostIntegration": { + "embeddingMode": "declarative", + "commandSurface": "slash-file", + "dispatch": { + "namedDispatch": true, + "nested": false, + "maxDepth": 1, + "background": true, + "subagentToolkit": "full", + "backgroundDispatch": false + }, + "modelMode": "passive", + "hookBus": "host", + "stateIO": "filesystem", + "transport": "mcp", + "runtime": "undocumented" + } } }, "cursor": { @@ -590,6 +748,7 @@ const capabilities = { "CURSOR_CONFIG_DIR" ] }, + "localConfigDir": ".cursor", "configFormat": "none", "artifactLayout": { "global": [ @@ -608,6 +767,14 @@ const capabilities = { "nesting": "flat", "recursive": false, "converter": "convertClaudeCommandToCursorCommand" + }, + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": "convertClaudeAgentToCursorAgent" } ], "local": [ @@ -626,6 +793,14 @@ const capabilities = { "nesting": "flat", "recursive": false, "converter": "convertClaudeCommandToCursorCommand" + }, + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": "convertClaudeAgentToCursorAgent" } ] }, @@ -637,7 +812,24 @@ const capabilities = { "installSurface": "cursor-hooks-json", "writesSharedSettings": false, "permissionWriter": null, - "extendedHookEvents": [] + "extendedHookEvents": [], + "hostIntegration": { + "embeddingMode": "imperative", + "commandSurface": "slash-file", + "dispatch": { + "namedDispatch": true, + "nested": true, + "maxDepth": 2, + "background": true, + "subagentToolkit": "full", + "backgroundDispatch": true + }, + "modelMode": "passive", + "hookBus": "host", + "stateIO": "filesystem", + "transport": "mcp", + "runtime": "node" + } } }, "drift": { @@ -778,6 +970,7 @@ const capabilities = { "GEMINI_CONFIG_DIR" ] }, + "localConfigDir": ".gemini", "configFormat": "settings-json", "artifactLayout": { "global": [ @@ -813,7 +1006,24 @@ const capabilities = { "BeforeAgent", "AfterAgent", "BeforeModel" - ] + ], + "hostIntegration": { + "embeddingMode": "declarative", + "commandSurface": "slash-toml", + "dispatch": { + "namedDispatch": true, + "nested": false, + "maxDepth": 1, + "background": "undocumented", + "subagentToolkit": "undocumented", + "backgroundDispatch": false + }, + "modelMode": "passive", + "hookBus": "host", + "stateIO": "filesystem", + "transport": "mcp", + "runtime": "node" + } } }, "graphify": { @@ -876,6 +1086,7 @@ const capabilities = { "HERMES_HOME" ] }, + "localConfigDir": ".hermes", "configFormat": "settings-json", "artifactLayout": { "global": [ @@ -907,7 +1118,24 @@ const capabilities = { "installSurface": "settings-json", "writesSharedSettings": true, "permissionWriter": null, - "extendedHookEvents": [] + "extendedHookEvents": [], + "hostIntegration": { + "embeddingMode": "imperative", + "commandSurface": "slash-programmatic", + "dispatch": { + "namedDispatch": false, + "nested": true, + "maxDepth": 1, + "background": true, + "subagentToolkit": "read-only", + "backgroundDispatch": false + }, + "modelMode": "active", + "hookBus": "host", + "stateIO": "filesystem", + "transport": "mcp", + "runtime": "python" + } } }, "intel": { @@ -988,6 +1216,7 @@ const capabilities = { "env": [] } }, + "localConfigDir": ".kilo", "configFormat": "settings-json", "artifactLayout": { "global": [ @@ -1034,7 +1263,24 @@ const capabilities = { "installSurface": "settings-json", "writesSharedSettings": false, "permissionWriter": "kilo", - "extendedHookEvents": [] + "extendedHookEvents": [], + "hostIntegration": { + "embeddingMode": "imperative", + "commandSurface": "slash-file", + "dispatch": { + "namedDispatch": true, + "nested": true, + "maxDepth": -1, + "background": true, + "subagentToolkit": "undocumented", + "backgroundDispatch": false + }, + "modelMode": "active", + "hookBus": "host", + "stateIO": "filesystem", + "transport": "mcp", + "runtime": "bun" + } } }, "kimi": { @@ -1061,6 +1307,7 @@ const capabilities = { ], "probeExists": "skills" }, + "localConfigDir": ".kimi-code", "configFormat": "none", "artifactLayout": { "global": [ @@ -1090,7 +1337,24 @@ const capabilities = { "installSurface": "profile-marker-only", "writesSharedSettings": false, "permissionWriter": null, - "extendedHookEvents": [] + "extendedHookEvents": [], + "hostIntegration": { + "embeddingMode": "imperative", + "commandSurface": "slash-file", + "dispatch": { + "namedDispatch": true, + "nested": false, + "maxDepth": 1, + "background": true, + "subagentToolkit": "undocumented", + "backgroundDispatch": false + }, + "modelMode": "passive", + "hookBus": "host", + "stateIO": "filesystem", + "transport": "mcp", + "runtime": "python" + } } }, "mempalace": { @@ -1338,6 +1602,7 @@ const capabilities = { "XDG_CONFIG_HOME" ] }, + "localConfigDir": ".opencode", "configFormat": "settings-json", "artifactLayout": { "global": [ @@ -1384,7 +1649,24 @@ const capabilities = { "installSurface": "settings-json", "writesSharedSettings": true, "permissionWriter": "opencode", - "extendedHookEvents": [] + "extendedHookEvents": [], + "hostIntegration": { + "embeddingMode": "imperative", + "commandSurface": "slash-file", + "dispatch": { + "namedDispatch": true, + "nested": "undocumented", + "maxDepth": "undocumented", + "background": false, + "subagentToolkit": "full", + "backgroundDispatch": "undocumented" + }, + "modelMode": "active", + "hookBus": "host", + "stateIO": "filesystem", + "transport": "mcp", + "runtime": "bun" + } } }, "pattern-mapper": { @@ -1537,6 +1819,7 @@ const capabilities = { "QWEN_CONFIG_DIR" ] }, + "localConfigDir": ".qwen", "configFormat": "settings-json", "artifactLayout": { "global": [ @@ -1572,7 +1855,24 @@ const capabilities = { "SubagentStop", "Stop", "PreCompact" - ] + ], + "hostIntegration": { + "embeddingMode": "imperative", + "commandSurface": "slash-file", + "dispatch": { + "namedDispatch": true, + "nested": false, + "maxDepth": 1, + "background": true, + "subagentToolkit": "full", + "backgroundDispatch": false + }, + "modelMode": "passive", + "hookBus": "host", + "stateIO": "filesystem", + "transport": "mcp", + "runtime": "node" + } } }, "research": { @@ -1844,6 +2144,7 @@ const capabilities = { "TRAE_CONFIG_DIR" ] }, + "localConfigDir": ".trae", "configFormat": "none", "artifactLayout": { "global": [ @@ -1854,6 +2155,14 @@ const capabilities = { "nesting": "nested", "recursive": false, "converter": "convertClaudeCommandToTraeSkill" + }, + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": "convertClaudeAgentToTraeAgent" } ], "local": [ @@ -1864,6 +2173,14 @@ const capabilities = { "nesting": "nested", "recursive": false, "converter": "convertClaudeCommandToTraeSkill" + }, + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": "convertClaudeAgentToTraeAgent" } ] }, @@ -1874,7 +2191,24 @@ const capabilities = { "installSurface": "profile-marker-only", "writesSharedSettings": false, "permissionWriter": null, - "extendedHookEvents": [] + "extendedHookEvents": [], + "hostIntegration": { + "embeddingMode": "imperative", + "commandSurface": "slash-file", + "dispatch": { + "namedDispatch": true, + "nested": "undocumented", + "maxDepth": "undocumented", + "background": true, + "subagentToolkit": "undocumented", + "backgroundDispatch": "undocumented" + }, + "modelMode": "passive", + "hookBus": "engine", + "stateIO": "filesystem", + "transport": "mcp", + "runtime": "node" + } } }, "ui": { @@ -1992,9 +2326,19 @@ const capabilities = { "WINDSURF_CONFIG_DIR" ] }, + "localConfigDir": ".windsurf", "configFormat": "none", "artifactLayout": { - "global": [], + "global": [ + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": "convertClaudeAgentToWindsurfAgent" + } + ], "local": [ { "kind": "commands", @@ -2003,6 +2347,14 @@ const capabilities = { "nesting": "flat", "recursive": false, "converter": "convertClaudeCommandToWindsurfWorkflow" + }, + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": "convertClaudeAgentToWindsurfAgent" } ] }, @@ -2013,7 +2365,24 @@ const capabilities = { "installSurface": "profile-marker-only", "writesSharedSettings": false, "permissionWriter": null, - "extendedHookEvents": [] + "extendedHookEvents": [], + "hostIntegration": { + "embeddingMode": "declarative", + "commandSurface": "slash-file", + "dispatch": { + "namedDispatch": "undocumented", + "nested": "undocumented", + "maxDepth": "undocumented", + "background": "undocumented", + "subagentToolkit": "undocumented", + "backgroundDispatch": "undocumented" + }, + "modelMode": "passive", + "hookBus": "host", + "stateIO": "filesystem", + "transport": "mcp", + "runtime": "undocumented" + } } } }; @@ -2766,6 +3135,7 @@ const runtimes = { ], "probeExists": "gsd-core/VERSION" }, + "localConfigDir": ".agents", "configFormat": "settings-json", "artifactLayout": { "global": [ @@ -2797,7 +3167,24 @@ const runtimes = { "installSurface": "settings-json", "writesSharedSettings": true, "permissionWriter": null, - "extendedHookEvents": [] + "extendedHookEvents": [], + "hostIntegration": { + "embeddingMode": "declarative", + "commandSurface": "slash-file", + "dispatch": { + "namedDispatch": "undocumented", + "nested": "undocumented", + "maxDepth": "undocumented", + "background": true, + "subagentToolkit": "undocumented", + "backgroundDispatch": "undocumented" + }, + "modelMode": "passive", + "hookBus": "host", + "stateIO": "filesystem", + "transport": "mcp", + "runtime": "go" + } } }, "augment": { @@ -2819,6 +3206,7 @@ const runtimes = { "AUGMENT_CONFIG_DIR" ] }, + "localConfigDir": ".augment", "configFormat": "settings-json", "artifactLayout": { "global": [ @@ -2837,6 +3225,14 @@ const runtimes = { "nesting": "nested", "recursive": false, "converter": "convertClaudeCommandToAugmentSkill" + }, + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": "convertClaudeAgentToAugmentAgent" } ], "local": [ @@ -2855,6 +3251,14 @@ const runtimes = { "nesting": "nested", "recursive": false, "converter": "convertClaudeCommandToAugmentSkill" + }, + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": "convertClaudeAgentToAugmentAgent" } ] }, @@ -2866,7 +3270,24 @@ const runtimes = { "installSurface": "settings-json", "writesSharedSettings": true, "permissionWriter": null, - "extendedHookEvents": [] + "extendedHookEvents": [], + "hostIntegration": { + "embeddingMode": "declarative", + "commandSurface": "slash-file", + "dispatch": { + "namedDispatch": true, + "nested": "undocumented", + "maxDepth": "undocumented", + "background": true, + "subagentToolkit": "full", + "backgroundDispatch": "undocumented" + }, + "modelMode": "passive", + "hookBus": "host", + "stateIO": "filesystem", + "transport": "mcp", + "runtime": "node" + } } }, "claude": { @@ -2888,6 +3309,7 @@ const runtimes = { "CLAUDE_CONFIG_DIR" ] }, + "localConfigDir": ".claude", "configFormat": "settings-json", "artifactLayout": { "global": [ @@ -2932,7 +3354,24 @@ const runtimes = { "Stop", "PreCompact", "FileChanged" - ] + ], + "hostIntegration": { + "embeddingMode": "imperative", + "commandSurface": "slash-file", + "dispatch": { + "namedDispatch": true, + "nested": true, + "maxDepth": 5, + "background": true, + "subagentToolkit": "full", + "backgroundDispatch": false + }, + "modelMode": "passive", + "hookBus": "host", + "stateIO": "filesystem", + "transport": "mcp", + "runtime": "node" + } } }, "cline": { @@ -2954,6 +3393,7 @@ const runtimes = { "CLINE_CONFIG_DIR" ] }, + "localConfigDir": ".cline", "configFormat": "markdown-dir", "artifactLayout": { "global": [ @@ -2975,7 +3415,24 @@ const runtimes = { "installSurface": "cline-rules", "writesSharedSettings": false, "permissionWriter": null, - "extendedHookEvents": [] + "extendedHookEvents": [], + "hostIntegration": { + "embeddingMode": "imperative", + "commandSurface": "slash-file", + "dispatch": { + "namedDispatch": true, + "nested": false, + "maxDepth": 1, + "background": true, + "subagentToolkit": "read-only", + "backgroundDispatch": false + }, + "modelMode": "active", + "hookBus": "host", + "stateIO": "filesystem", + "transport": "mcp", + "runtime": "node" + } } }, "codebuddy": { @@ -2997,6 +3454,7 @@ const runtimes = { "CODEBUDDY_CONFIG_DIR" ] }, + "localConfigDir": ".codebuddy", "configFormat": "settings-json", "artifactLayout": { "global": [ @@ -3015,6 +3473,14 @@ const runtimes = { "nesting": "flat", "recursive": false, "converter": "convertClaudeCommandToCodebuddySkill" + }, + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": "convertClaudeAgentToCodebuddyAgent" } ], "local": [ @@ -3033,6 +3499,14 @@ const runtimes = { "nesting": "flat", "recursive": false, "converter": "convertClaudeCommandToCodebuddySkill" + }, + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": "convertClaudeAgentToCodebuddyAgent" } ] }, @@ -3044,7 +3518,24 @@ const runtimes = { "installSurface": "settings-json", "writesSharedSettings": true, "permissionWriter": null, - "extendedHookEvents": [] + "extendedHookEvents": [], + "hostIntegration": { + "embeddingMode": "declarative", + "commandSurface": "slash-file", + "dispatch": { + "namedDispatch": true, + "nested": false, + "maxDepth": 1, + "background": true, + "subagentToolkit": "full", + "backgroundDispatch": false + }, + "modelMode": "passive", + "hookBus": "host", + "stateIO": "filesystem", + "transport": "mcp", + "runtime": "node" + } } }, "codex": { @@ -3066,6 +3557,7 @@ const runtimes = { "CODEX_HOME" ] }, + "localConfigDir": ".codex", "configFormat": "toml", "artifactLayout": { "global": [ @@ -3097,7 +3589,24 @@ const runtimes = { "installSurface": "codex-toml", "writesSharedSettings": false, "permissionWriter": null, - "extendedHookEvents": [] + "extendedHookEvents": [], + "hostIntegration": { + "embeddingMode": "declarative", + "commandSurface": "slash-file", + "dispatch": { + "namedDispatch": true, + "nested": true, + "maxDepth": 1, + "background": true, + "subagentToolkit": "full", + "backgroundDispatch": true + }, + "modelMode": "passive", + "hookBus": "host", + "stateIO": "filesystem", + "transport": "mcp", + "runtime": "node" + } } }, "copilot": { @@ -3120,6 +3629,7 @@ const runtimes = { "COPILOT_HOME" ] }, + "localConfigDir": ".github", "configFormat": "markdown", "artifactLayout": { "global": [ @@ -3150,7 +3660,24 @@ const runtimes = { "installSurface": "copilot-instructions", "writesSharedSettings": false, "permissionWriter": null, - "extendedHookEvents": [] + "extendedHookEvents": [], + "hostIntegration": { + "embeddingMode": "declarative", + "commandSurface": "slash-file", + "dispatch": { + "namedDispatch": true, + "nested": false, + "maxDepth": 1, + "background": true, + "subagentToolkit": "full", + "backgroundDispatch": false + }, + "modelMode": "passive", + "hookBus": "host", + "stateIO": "filesystem", + "transport": "mcp", + "runtime": "undocumented" + } } }, "cursor": { @@ -3172,6 +3699,7 @@ const runtimes = { "CURSOR_CONFIG_DIR" ] }, + "localConfigDir": ".cursor", "configFormat": "none", "artifactLayout": { "global": [ @@ -3190,6 +3718,14 @@ const runtimes = { "nesting": "flat", "recursive": false, "converter": "convertClaudeCommandToCursorCommand" + }, + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": "convertClaudeAgentToCursorAgent" } ], "local": [ @@ -3208,6 +3744,14 @@ const runtimes = { "nesting": "flat", "recursive": false, "converter": "convertClaudeCommandToCursorCommand" + }, + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": "convertClaudeAgentToCursorAgent" } ] }, @@ -3219,7 +3763,24 @@ const runtimes = { "installSurface": "cursor-hooks-json", "writesSharedSettings": false, "permissionWriter": null, - "extendedHookEvents": [] + "extendedHookEvents": [], + "hostIntegration": { + "embeddingMode": "imperative", + "commandSurface": "slash-file", + "dispatch": { + "namedDispatch": true, + "nested": true, + "maxDepth": 2, + "background": true, + "subagentToolkit": "full", + "backgroundDispatch": true + }, + "modelMode": "passive", + "hookBus": "host", + "stateIO": "filesystem", + "transport": "mcp", + "runtime": "node" + } } }, "gemini": { @@ -3241,6 +3802,7 @@ const runtimes = { "GEMINI_CONFIG_DIR" ] }, + "localConfigDir": ".gemini", "configFormat": "settings-json", "artifactLayout": { "global": [ @@ -3276,7 +3838,24 @@ const runtimes = { "BeforeAgent", "AfterAgent", "BeforeModel" - ] + ], + "hostIntegration": { + "embeddingMode": "declarative", + "commandSurface": "slash-toml", + "dispatch": { + "namedDispatch": true, + "nested": false, + "maxDepth": 1, + "background": "undocumented", + "subagentToolkit": "undocumented", + "backgroundDispatch": false + }, + "modelMode": "passive", + "hookBus": "host", + "stateIO": "filesystem", + "transport": "mcp", + "runtime": "node" + } } }, "hermes": { @@ -3298,6 +3877,7 @@ const runtimes = { "HERMES_HOME" ] }, + "localConfigDir": ".hermes", "configFormat": "settings-json", "artifactLayout": { "global": [ @@ -3329,7 +3909,24 @@ const runtimes = { "installSurface": "settings-json", "writesSharedSettings": true, "permissionWriter": null, - "extendedHookEvents": [] + "extendedHookEvents": [], + "hostIntegration": { + "embeddingMode": "imperative", + "commandSurface": "slash-programmatic", + "dispatch": { + "namedDispatch": false, + "nested": true, + "maxDepth": 1, + "background": true, + "subagentToolkit": "read-only", + "backgroundDispatch": false + }, + "modelMode": "active", + "hookBus": "host", + "stateIO": "filesystem", + "transport": "mcp", + "runtime": "python" + } } }, "kilo": { @@ -3358,6 +3955,7 @@ const runtimes = { "env": [] } }, + "localConfigDir": ".kilo", "configFormat": "settings-json", "artifactLayout": { "global": [ @@ -3404,7 +4002,24 @@ const runtimes = { "installSurface": "settings-json", "writesSharedSettings": false, "permissionWriter": "kilo", - "extendedHookEvents": [] + "extendedHookEvents": [], + "hostIntegration": { + "embeddingMode": "imperative", + "commandSurface": "slash-file", + "dispatch": { + "namedDispatch": true, + "nested": true, + "maxDepth": -1, + "background": true, + "subagentToolkit": "undocumented", + "backgroundDispatch": false + }, + "modelMode": "active", + "hookBus": "host", + "stateIO": "filesystem", + "transport": "mcp", + "runtime": "bun" + } } }, "kimi": { @@ -3431,6 +4046,7 @@ const runtimes = { ], "probeExists": "skills" }, + "localConfigDir": ".kimi-code", "configFormat": "none", "artifactLayout": { "global": [ @@ -3460,7 +4076,24 @@ const runtimes = { "installSurface": "profile-marker-only", "writesSharedSettings": false, "permissionWriter": null, - "extendedHookEvents": [] + "extendedHookEvents": [], + "hostIntegration": { + "embeddingMode": "imperative", + "commandSurface": "slash-file", + "dispatch": { + "namedDispatch": true, + "nested": false, + "maxDepth": 1, + "background": true, + "subagentToolkit": "undocumented", + "backgroundDispatch": false + }, + "modelMode": "passive", + "hookBus": "host", + "stateIO": "filesystem", + "transport": "mcp", + "runtime": "python" + } } }, "opencode": { @@ -3484,6 +4117,7 @@ const runtimes = { "XDG_CONFIG_HOME" ] }, + "localConfigDir": ".opencode", "configFormat": "settings-json", "artifactLayout": { "global": [ @@ -3530,7 +4164,24 @@ const runtimes = { "installSurface": "settings-json", "writesSharedSettings": true, "permissionWriter": "opencode", - "extendedHookEvents": [] + "extendedHookEvents": [], + "hostIntegration": { + "embeddingMode": "imperative", + "commandSurface": "slash-file", + "dispatch": { + "namedDispatch": true, + "nested": "undocumented", + "maxDepth": "undocumented", + "background": false, + "subagentToolkit": "full", + "backgroundDispatch": "undocumented" + }, + "modelMode": "active", + "hookBus": "host", + "stateIO": "filesystem", + "transport": "mcp", + "runtime": "bun" + } } }, "qwen": { @@ -3552,6 +4203,7 @@ const runtimes = { "QWEN_CONFIG_DIR" ] }, + "localConfigDir": ".qwen", "configFormat": "settings-json", "artifactLayout": { "global": [ @@ -3587,7 +4239,24 @@ const runtimes = { "SubagentStop", "Stop", "PreCompact" - ] + ], + "hostIntegration": { + "embeddingMode": "imperative", + "commandSurface": "slash-file", + "dispatch": { + "namedDispatch": true, + "nested": false, + "maxDepth": 1, + "background": true, + "subagentToolkit": "full", + "backgroundDispatch": false + }, + "modelMode": "passive", + "hookBus": "host", + "stateIO": "filesystem", + "transport": "mcp", + "runtime": "node" + } } }, "trae": { @@ -3609,6 +4278,7 @@ const runtimes = { "TRAE_CONFIG_DIR" ] }, + "localConfigDir": ".trae", "configFormat": "none", "artifactLayout": { "global": [ @@ -3619,6 +4289,14 @@ const runtimes = { "nesting": "nested", "recursive": false, "converter": "convertClaudeCommandToTraeSkill" + }, + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": "convertClaudeAgentToTraeAgent" } ], "local": [ @@ -3629,6 +4307,14 @@ const runtimes = { "nesting": "nested", "recursive": false, "converter": "convertClaudeCommandToTraeSkill" + }, + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": "convertClaudeAgentToTraeAgent" } ] }, @@ -3639,7 +4325,24 @@ const runtimes = { "installSurface": "profile-marker-only", "writesSharedSettings": false, "permissionWriter": null, - "extendedHookEvents": [] + "extendedHookEvents": [], + "hostIntegration": { + "embeddingMode": "imperative", + "commandSurface": "slash-file", + "dispatch": { + "namedDispatch": true, + "nested": "undocumented", + "maxDepth": "undocumented", + "background": true, + "subagentToolkit": "undocumented", + "backgroundDispatch": "undocumented" + }, + "modelMode": "passive", + "hookBus": "engine", + "stateIO": "filesystem", + "transport": "mcp", + "runtime": "node" + } } }, "windsurf": { @@ -3662,9 +4365,19 @@ const runtimes = { "WINDSURF_CONFIG_DIR" ] }, + "localConfigDir": ".windsurf", "configFormat": "none", "artifactLayout": { - "global": [], + "global": [ + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": "convertClaudeAgentToWindsurfAgent" + } + ], "local": [ { "kind": "commands", @@ -3673,6 +4386,14 @@ const runtimes = { "nesting": "flat", "recursive": false, "converter": "convertClaudeCommandToWindsurfWorkflow" + }, + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": "convertClaudeAgentToWindsurfAgent" } ] }, @@ -3683,7 +4404,24 @@ const runtimes = { "installSurface": "profile-marker-only", "writesSharedSettings": false, "permissionWriter": null, - "extendedHookEvents": [] + "extendedHookEvents": [], + "hostIntegration": { + "embeddingMode": "declarative", + "commandSurface": "slash-file", + "dispatch": { + "namedDispatch": "undocumented", + "nested": "undocumented", + "maxDepth": "undocumented", + "background": "undocumented", + "subagentToolkit": "undocumented", + "backgroundDispatch": "undocumented" + }, + "modelMode": "passive", + "hookBus": "host", + "stateIO": "filesystem", + "transport": "mcp", + "runtime": "undocumented" + } } } }; diff --git a/gsd-core/bin/lib/capability-validator.cjs b/gsd-core/bin/lib/capability-validator.cjs index 6f3cad16b..6d936b90d 100644 --- a/gsd-core/bin/lib/capability-validator.cjs +++ b/gsd-core/bin/lib/capability-validator.cjs @@ -713,6 +713,16 @@ const VALID_INSTALL_SURFACES = new Set(['settings-json', 'codex-toml', 'copilot- const VALID_PERMISSION_WRITERS = new Set(['opencode', 'kilo']); const VALID_EXTENDED_HOOK_EVENTS = new Set(['SubagentStop', 'Stop', 'PreCompact', 'FileChanged', 'BeforeAgent', 'AfterAgent', 'BeforeModel']); +// ADR-1239 Phase A: hostIntegration axes (MUST stay parity-identical to HOST_INTEGRATION_AXES in src/host-integration.cts) +const VALID_EMBEDDING_MODES = new Set(['imperative', 'declarative']); +const VALID_COMMAND_SURFACES = new Set(['slash-file', 'slash-programmatic', 'slash-toml', 'palette', 'prose-only']); +const VALID_MODEL_MODES = new Set(['active', 'passive']); +const VALID_HOOK_BUSES = new Set(['host', 'engine', 'none']); +const VALID_STATE_IO = new Set(['filesystem', 'sandboxed-storage', 'session-log-append']); +const VALID_TRANSPORTS = new Set(['mcp', 'native-extension']); +const VALID_HOST_RUNTIMES = new Set(['node', 'bun', 'sandboxed-web', 'python', 'go', 'rust', 'electron', 'other']); +const VALID_SUBAGENT_TOOLKITS = new Set(['full', 'read-only']); + // GATE A: installSurface → allowed hooksSurface values (DEFECT.GENERATIVE-FIX: parity invariant) // Derived from the actual pairings in the 16 real runtime descriptors. const INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES = new Map([ @@ -1020,6 +1030,20 @@ function validateRuntimeBody(cap) { ); } + // localConfigDir — REQUIRED non-empty dot-dir string (ADR-1239 Phase B #1679) + // Must start with '.' (e.g. ".claude", ".cursor"). Validated here so the registry + // generator catches any descriptor missing the field before regenerating. + if (typeof r.localConfigDir !== 'string' || r.localConfigDir.length === 0) { + errors.push( + 'runtime.localConfigDir is required and must be a non-empty string (e.g. ".claude"); ' + + 'got: ' + JSON.stringify(r.localConfigDir), + ); + } else if (!r.localConfigDir.startsWith('.')) { + errors.push( + 'runtime.localConfigDir must start with "." (a dot-dir); got: ' + JSON.stringify(r.localConfigDir), + ); + } + // extendedHookEvents — required array; every element must be in closed enum if (!Array.isArray(r.extendedHookEvents)) { errors.push( @@ -1037,6 +1061,162 @@ function validateRuntimeBody(cap) { } } + // hostIntegration — ADR-1239 Phase A: required object with closed-enum axes + if (typeof r.hostIntegration !== 'object' || r.hostIntegration === null || Array.isArray(r.hostIntegration)) { + errors.push('runtime.hostIntegration is required and must be an object'); + } else { + const hi = r.hostIntegration; + + // S2b: reserved-OWN-KEY guard on hostIntegration (CodeQL barrier — inline literal comparisons) + if (Object.prototype.hasOwnProperty.call(hi, '__proto__')) { + errors.push('runtime.hostIntegration must not contain reserved key "__proto__"'); + } + if (Object.prototype.hasOwnProperty.call(hi, 'constructor')) { + errors.push('runtime.hostIntegration must not contain reserved key "constructor"'); + } + if (Object.prototype.hasOwnProperty.call(hi, 'prototype')) { + errors.push('runtime.hostIntegration must not contain reserved key "prototype"'); + } + + // embeddingMode + if (hi.embeddingMode === '__proto__' || hi.embeddingMode === 'constructor' || hi.embeddingMode === 'prototype') { + errors.push('runtime.hostIntegration.embeddingMode "' + hi.embeddingMode + '" is a reserved name'); + } else if (hi.embeddingMode !== 'undocumented' && !VALID_EMBEDDING_MODES.has(hi.embeddingMode)) { + errors.push( + 'runtime.hostIntegration.embeddingMode must be one of: ' + [...VALID_EMBEDDING_MODES].join(', ') + + ' (or "undocumented") (got: ' + JSON.stringify(hi.embeddingMode) + ')', + ); + } + + // commandSurface + if (hi.commandSurface === '__proto__' || hi.commandSurface === 'constructor' || hi.commandSurface === 'prototype') { + errors.push('runtime.hostIntegration.commandSurface "' + hi.commandSurface + '" is a reserved name'); + } else if (hi.commandSurface !== 'undocumented' && !VALID_COMMAND_SURFACES.has(hi.commandSurface)) { + errors.push( + 'runtime.hostIntegration.commandSurface must be one of: ' + [...VALID_COMMAND_SURFACES].join(', ') + + ' (or "undocumented") (got: ' + JSON.stringify(hi.commandSurface) + ')', + ); + } + + // modelMode + if (hi.modelMode === '__proto__' || hi.modelMode === 'constructor' || hi.modelMode === 'prototype') { + errors.push('runtime.hostIntegration.modelMode "' + hi.modelMode + '" is a reserved name'); + } else if (hi.modelMode !== 'undocumented' && !VALID_MODEL_MODES.has(hi.modelMode)) { + errors.push( + 'runtime.hostIntegration.modelMode must be one of: ' + [...VALID_MODEL_MODES].join(', ') + + ' (or "undocumented") (got: ' + JSON.stringify(hi.modelMode) + ')', + ); + } + + // hookBus + if (hi.hookBus === '__proto__' || hi.hookBus === 'constructor' || hi.hookBus === 'prototype') { + errors.push('runtime.hostIntegration.hookBus "' + hi.hookBus + '" is a reserved name'); + } else if (hi.hookBus !== 'undocumented' && !VALID_HOOK_BUSES.has(hi.hookBus)) { + errors.push( + 'runtime.hostIntegration.hookBus must be one of: ' + [...VALID_HOOK_BUSES].join(', ') + + ' (or "undocumented") (got: ' + JSON.stringify(hi.hookBus) + ')', + ); + } + + // stateIO + if (hi.stateIO === '__proto__' || hi.stateIO === 'constructor' || hi.stateIO === 'prototype') { + errors.push('runtime.hostIntegration.stateIO "' + hi.stateIO + '" is a reserved name'); + } else if (hi.stateIO !== 'undocumented' && !VALID_STATE_IO.has(hi.stateIO)) { + errors.push( + 'runtime.hostIntegration.stateIO must be one of: ' + [...VALID_STATE_IO].join(', ') + + ' (or "undocumented") (got: ' + JSON.stringify(hi.stateIO) + ')', + ); + } + + // transport + if (hi.transport === '__proto__' || hi.transport === 'constructor' || hi.transport === 'prototype') { + errors.push('runtime.hostIntegration.transport "' + hi.transport + '" is a reserved name'); + } else if (hi.transport !== 'undocumented' && !VALID_TRANSPORTS.has(hi.transport)) { + errors.push( + 'runtime.hostIntegration.transport must be one of: ' + [...VALID_TRANSPORTS].join(', ') + + ' (or "undocumented") (got: ' + JSON.stringify(hi.transport) + ')', + ); + } + + // runtime (axis) + if (hi.runtime === '__proto__' || hi.runtime === 'constructor' || hi.runtime === 'prototype') { + errors.push('runtime.hostIntegration.runtime "' + hi.runtime + '" is a reserved name'); + } else if (hi.runtime !== 'undocumented' && !VALID_HOST_RUNTIMES.has(hi.runtime)) { + errors.push( + 'runtime.hostIntegration.runtime must be one of: ' + [...VALID_HOST_RUNTIMES].join(', ') + + ' (or "undocumented") (got: ' + JSON.stringify(hi.runtime) + ')', + ); + } + + // dispatch — required object + if (typeof hi.dispatch !== 'object' || hi.dispatch === null || Array.isArray(hi.dispatch)) { + errors.push('runtime.hostIntegration.dispatch must be an object'); + } else { + const d = hi.dispatch; + + // S2b: reserved-OWN-KEY guard on dispatch (CodeQL barrier — inline literal comparisons) + if (Object.prototype.hasOwnProperty.call(d, '__proto__')) { + errors.push('runtime.hostIntegration.dispatch must not contain reserved key "__proto__"'); + } + if (Object.prototype.hasOwnProperty.call(d, 'constructor')) { + errors.push('runtime.hostIntegration.dispatch must not contain reserved key "constructor"'); + } + if (Object.prototype.hasOwnProperty.call(d, 'prototype')) { + errors.push('runtime.hostIntegration.dispatch must not contain reserved key "prototype"'); + } + + // namedDispatch — boolean or 'undocumented' + if (typeof d.namedDispatch !== 'boolean' && d.namedDispatch !== 'undocumented') { + errors.push( + 'runtime.hostIntegration.dispatch.namedDispatch must be a boolean or "undocumented" (got: ' + JSON.stringify(d.namedDispatch) + ')', + ); + } + + // nested — boolean or 'undocumented' + if (typeof d.nested !== 'boolean' && d.nested !== 'undocumented') { + errors.push( + 'runtime.hostIntegration.dispatch.nested must be a boolean or "undocumented" (got: ' + JSON.stringify(d.nested) + ')', + ); + } + + // background — boolean or 'undocumented' + if (typeof d.background !== 'boolean' && d.background !== 'undocumented') { + errors.push( + 'runtime.hostIntegration.dispatch.background must be a boolean or "undocumented" (got: ' + JSON.stringify(d.background) + ')', + ); + } + + // subagentToolkit — closed enum or 'undocumented' + if (d.subagentToolkit === '__proto__' || d.subagentToolkit === 'constructor' || d.subagentToolkit === 'prototype') { + errors.push('runtime.hostIntegration.dispatch.subagentToolkit "' + d.subagentToolkit + '" is a reserved name'); + } else if (d.subagentToolkit !== 'undocumented' && !VALID_SUBAGENT_TOOLKITS.has(d.subagentToolkit)) { + errors.push( + 'runtime.hostIntegration.dispatch.subagentToolkit must be one of: ' + [...VALID_SUBAGENT_TOOLKITS].join(', ') + + ' (or "undocumented") (got: ' + JSON.stringify(d.subagentToolkit) + ')', + ); + } + + // maxDepth — integer >= -1 or 'undocumented' + if (d.maxDepth !== 'undocumented' && (!Number.isInteger(d.maxDepth) || d.maxDepth < -1)) { + errors.push( + 'runtime.hostIntegration.dispatch.maxDepth must be an integer >= -1 or "undocumented" (got: ' + JSON.stringify(d.maxDepth) + ')', + ); + } + + // backgroundDispatch — REQUIRED (all 16 runtime descriptors carry it, matching the sibling fields + // namedDispatch/nested/background/subagentToolkit/maxDepth which are all required). + if (!Object.prototype.hasOwnProperty.call(d, 'backgroundDispatch')) { + errors.push( + 'runtime.hostIntegration.dispatch.backgroundDispatch is required (must be a boolean or "undocumented")', + ); + } else if (typeof d.backgroundDispatch !== 'boolean' && d.backgroundDispatch !== 'undocumented') { + errors.push( + 'runtime.hostIntegration.dispatch.backgroundDispatch must be a boolean or "undocumented" (got: ' + JSON.stringify(d.backgroundDispatch) + ')', + ); + } + } + } + // GATE A: installSurface ↔ hooksSurface consistency (DEFECT.GENERATIVE-FIX) // Only check if both fields are valid strings (individual field validators above report type errors). if (typeof r.installSurface === 'string' && typeof r.hooksSurface === 'string') { @@ -2052,6 +2232,24 @@ module.exports = { VALID_INSTALL_SURFACES, VALID_PERMISSION_WRITERS, VALID_EXTENDED_HOOK_EVENTS, + VALID_EMBEDDING_MODES, + VALID_COMMAND_SURFACES, + VALID_MODEL_MODES, + VALID_HOOK_BUSES, + VALID_STATE_IO, + VALID_TRANSPORTS, + VALID_HOST_RUNTIMES, + VALID_SUBAGENT_TOOLKITS, + _HOST_INTEGRATION_VOCAB: { + embeddingMode: [...VALID_EMBEDDING_MODES], + commandSurface: [...VALID_COMMAND_SURFACES], + modelMode: [...VALID_MODEL_MODES], + hookBus: [...VALID_HOOK_BUSES], + stateIO: [...VALID_STATE_IO], + transport: [...VALID_TRANSPORTS], + runtime: [...VALID_HOST_RUNTIMES], + subagentToolkit: [...VALID_SUBAGENT_TOOLKITS], + }, INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES, GEMINI_AGENT_EVENTS, CLAUDE_FAMILY_EVENTS, diff --git a/gsd-core/bin/lib/runtime-artifact-install-plan.cjs b/gsd-core/bin/lib/runtime-artifact-install-plan.cjs index 9a89d8e6d..e05aad2ec 100644 --- a/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +++ b/gsd-core/bin/lib/runtime-artifact-install-plan.cjs @@ -9,6 +9,30 @@ // In .cts (CommonJS output) files, `require` is available as a global. const _require = require; const path = _require('node:path'); +/** + * Asserts that `destSubpath` resolves to a path inside `configDir`. + * + * Rejects any path that escapes the configDir root (e.g. "../../etc") and any + * path containing a NUL byte. This is a security gate for Phase B of + * ADR-1239: third-party descriptors must never be able to write outside the + * designated config home directory. + * + * @param configDir - The root config directory (e.g. ~/.claude). + * @param destSubpath - The relative path declared by the runtime descriptor. + * @returns The resolved absolute path under configDir. + * @throws {Error} if destSubpath escapes configDir or contains a NUL byte. + */ +function assertDestWithinConfigHome(configDir, destSubpath) { + if (destSubpath.includes('\0')) { + throw new Error(`destSubpath "${destSubpath}" contains a NUL byte and is not valid`); + } + const root = path.resolve(configDir); + const resolved = path.resolve(configDir, destSubpath); + if (resolved === root || !resolved.startsWith(root + path.sep)) { + throw new Error(`destSubpath "${destSubpath}" must be a strict subpath of configHome "${configDir}" — not configHome itself or outside it (escapes configHome)`); + } + return resolved; +} function errorMessage(err) { if (err instanceof Error) return err.message; @@ -36,10 +60,34 @@ function createRuntimeArtifactInstallPlan(args) { platform, resolveAttribution, }; + // ADR-1235 §1: build agentCtx once per plan so agents kind entries can apply + // the CORRECT pre-converter cross-cutting (path rewrites → attribution → converter + // → normalize). This mirrors the exact per-file order in the inline agent loop + // in bin/install.js (lines 9330-9415). agentCtx is passed as the second arg + // to kind.stage() for agents kind entries with a converter (convertedAgentsKind). + // NO _stampNonClaudeRuntimeDefaults — agents are NOT stamped in the inline loop. + const os = _require('node:os'); + const homedirFn = homedir ?? (() => os.homedir()); + const resolvedTarget = path.resolve(layout.configDir).replace(/\\/g, '/'); + const homeDir = homedirFn().replace(/\\/g, '/'); + const isGlobal = scope === 'global'; + const isOpencode = layout.runtime === 'opencode'; + const isWindowsHost = (platform ?? process.platform) === 'win32'; + const pathPrefix = conversionExports._computePathPrefix({ isGlobal, isOpencode, isWindowsHost, resolvedTarget, homeDir }); + const attribution = resolveAttribution ? resolveAttribution(layout.runtime) : undefined; + const agentCtx = { runtime: layout.runtime, pathPrefix, attribution }; for (const kind of layout.kinds) { let stagedDir; try { - stagedDir = kind.stage(resolvedProfile); + if (kind.kind === 'agents') { + // ADR-1235 §1: pass agentCtx so stageAgentsForRuntimeWithConverter applies + // the full inline-loop order: pathRewrites → attribution → converter → normalize. + // The cross-cutting is now PRE-converter (inside staging), not POST. + stagedDir = kind.stage(resolvedProfile, agentCtx); + } + else { + stagedDir = kind.stage(resolvedProfile); + } } catch (err) { return { ok: false, kind: 'stage_failed', message: errorMessage(err), cleanupDirs, failedKind: kind.kind }; @@ -54,6 +102,8 @@ function createRuntimeArtifactInstallPlan(args) { const rewrittenDir = rewriteStagedSkillBodies(stagedDir, rewriteOpts); sourceDir = addCleanupDir(cleanupDirs, stagedDir, rewrittenDir); } + // agents kind: cross-cutting already applied INSIDE kind.stage() via agentCtx. + // No POST-step needed. sourceDir stays as stagedDir. } catch (err) { return { ok: false, kind: 'rewrite_failed', message: errorMessage(err), cleanupDirs, failedKind: kind.kind }; @@ -61,7 +111,7 @@ function createRuntimeArtifactInstallPlan(args) { items.push({ kind: kind.kind, sourceDir, - destDir: path.join(layout.configDir, kind.destSubpath), + destDir: assertDestWithinConfigHome(layout.configDir, kind.destSubpath), }); } return { ok: true, plan: { items, cleanupDirs } }; @@ -70,8 +120,8 @@ function createRuntimeArtifactUninstallPlan(layout) { return { items: layout.kinds.map((kind) => ({ kind: kind.kind, - destDir: path.join(layout.configDir, kind.destSubpath), + destDir: assertDestWithinConfigHome(layout.configDir, kind.destSubpath), })), }; } -module.exports = { createRuntimeArtifactInstallPlan, createRuntimeArtifactUninstallPlan }; +module.exports = { assertDestWithinConfigHome, createRuntimeArtifactInstallPlan, createRuntimeArtifactUninstallPlan }; diff --git a/gsd-core/workflows/autonomous.md b/gsd-core/workflows/autonomous.md index 3e259f6c9..6f779630a 100644 --- a/gsd-core/workflows/autonomous.md +++ b/gsd-core/workflows/autonomous.md @@ -61,7 +61,7 @@ fi When `--only` is set, also set `FROM_PHASE` to the same value so existing filter logic applies. -When `--interactive` is set, discuss runs inline with questions. On Codex, where a backgrounded agent can still spawn subagents, plan and execute are dispatched as background agents — keeping the main context lean (only discuss conversations accumulate) and enabling overlap. On every other runtime (Claude Code and all other non-Codex runtimes), backgrounded agents cannot reliably nest subagents, so plan and execute run inline to preserve worktree isolation and independent verification, and phases run sequentially with their work accumulating in the main context. Either way, user input is preserved on all design decisions. +When `--interactive` is set, discuss runs inline with questions. When `dispatch-should-flatten` returns `false` (e.g. codex, cursor — runtimes where a backgrounded agent can still spawn subagents), plan and execute are dispatched as background agents — keeping the main context lean (only discuss conversations accumulate) and enabling overlap. When `dispatch-should-flatten` returns `true` (e.g. claude and other runtimes where backgrounded agents cannot reliably nest subagents), plan and execute run inline to preserve worktree isolation and independent verification, and phases run sequentially with their work accumulating in the main context. Either way, user input is preserved on all design decisions. When `PLAN_STRATEGY=converge`, the planning step MUST invoke the plan-review convergence workflow instead of `gsd-plan-phase`. `--cross-ai` is an alias for `--converge`. Forward `CONVERGENCE_ARGS` exactly as parsed so reviewer flags and `--max-cycles N` retain the same meaning as they have on `/gsd:plan-review-convergence`. @@ -358,13 +358,13 @@ UI_SPEC_FILE=$(ls "${PHASE_DIR}"/*-UI-SPEC.md 2>/dev/null | head -1) **3b. Plan** -**If `INTERACTIVE` is set:** Background dispatch is only safe on a runtime where a backgrounded agent can still nest the pipeline's subagents (plan-checker / worktree executors / verifier). Among supported runtimes only **Codex** (`spawn_agent`) can do this; Claude Code's backgrounded agents have no `Agent`/`Task` tool, and every other runtime either prohibits nested subagents or disables them by default. So run **inline** everywhere except Codex, which is dispatched in the background. Resolve the runtime first: +**If `INTERACTIVE` is set:** Background dispatch is only safe on a runtime where a backgrounded agent can still nest the pipeline's subagents (plan-checker / worktree executors / verifier). This is determined from the documentation-sourced dispatch capability in the registry (#1708); Claude Code's backgrounded agents have no `Agent`/`Task` tool, and every other runtime either prohibits nested subagents or disables them by default. So run **inline** everywhere except where `dispatch-should-flatten` returns `false`. Resolve first: ```bash -RUNTIME=$(gsd_run query config-get runtime --default claude --raw 2>/dev/null || echo "claude") +FLATTEN=$(gsd_run query dispatch-should-flatten --raw 2>/dev/null || echo "true") ``` -- **If `RUNTIME` is `codex`:** Dispatch plan as a background agent to keep the main context lean. While plan runs, the workflow can immediately start discussing the next phase (see step 4). +- **If `FLATTEN` is `false`:** Dispatch plan as a background agent to keep the main context lean. While plan runs, the workflow can immediately start discussing the next phase (see step 4). - If `PLAN_STRATEGY=converge`, print: `◆ Spawning background plan-convergence loop for phase ${PHASE_NUM}... (runs in a subagent — no output until it returns, ~1–5 min; expected, not a freeze)` @@ -388,7 +388,7 @@ RUNTIME=$(gsd_run query config-get runtime --default claude --raw 2>/dev/null || Store the agent task_id. After discuss for the next phase completes (or if no next phase), wait for the plan agent to finish before proceeding to execute. -- **Otherwise (Claude Code or any other non-Codex runtime):** Run plan **inline** (do NOT background) so the plan-checker runs. The next phase's discuss does not overlap planning here — correctness over overlap. +- **Otherwise (`FLATTEN` is `true` — run inline):** Run plan **inline** (do NOT background) so the plan-checker runs. The next phase's discuss does not overlap planning here — correctness over overlap. - If `PLAN_STRATEGY=converge`: @@ -420,13 +420,13 @@ Verify plan produced output — re-run `init phase-op` and check `has_plans`. If **3c. Execute** -**If `INTERACTIVE` is set:** Wait for the plan agent to complete (if not already) and verify plans exist. Background dispatch is only safe on a runtime where a backgrounded agent can still nest the pipeline's subagents (plan-checker / worktree executors / verifier). Among supported runtimes only **Codex** (`spawn_agent`) can do this; Claude Code's backgrounded agents have no `Agent`/`Task` tool, and every other runtime either prohibits nested subagents or disables them by default. So run **inline** everywhere except Codex, which is dispatched in the background. Resolve the runtime first: +**If `INTERACTIVE` is set:** Wait for the plan agent to complete (if not already) and verify plans exist. Background dispatch is only safe on a runtime where a backgrounded agent can still nest the pipeline's subagents (plan-checker / worktree executors / verifier). This is determined from the documentation-sourced dispatch capability in the registry (#1708); Claude Code's backgrounded agents have no `Agent`/`Task` tool, and every other runtime either prohibits nested subagents or disables them by default. So run **inline** everywhere except where `dispatch-should-flatten` returns `false`. Resolve first: ```bash -RUNTIME=$(gsd_run query config-get runtime --default claude --raw 2>/dev/null || echo "claude") +FLATTEN=$(gsd_run query dispatch-should-flatten --raw 2>/dev/null || echo "true") ``` -- **If `RUNTIME` is `codex`:** Dispatch execute as a background agent: +- **If `FLATTEN` is `false`:** Dispatch execute as a background agent: ``` Agent( @@ -438,7 +438,7 @@ Agent( Store the agent task_id. The workflow can now start discussing the next phase while this phase executes in the background. Before starting post-execution routing for this phase, wait for the execute agent to complete. -- **Otherwise (Claude Code or any other non-Codex runtime):** Run execute **inline** (do NOT background) so worktree isolation and verification run: +- **Otherwise (`FLATTEN` is `true` — run inline):** Run execute **inline** (do NOT background) so worktree isolation and verification run: ``` Skill(skill="gsd-execute-phase", args="${PHASE_NUM} --no-transition") diff --git a/gsd-core/workflows/manager.md b/gsd-core/workflows/manager.md index 92a30bf68..9d45dee34 100644 --- a/gsd-core/workflows/manager.md +++ b/gsd-core/workflows/manager.md @@ -1,6 +1,6 @@ -Interactive command center for managing a milestone from a single terminal. Shows a dashboard of all phases with visual status, dispatches discuss inline and runs plan/execute inline (backgrounded only on Codex), and loops back to the dashboard after each action. Enables parallel phase work from one terminal. +Interactive command center for managing a milestone from a single terminal. Shows a dashboard of all phases with visual status, dispatches discuss inline and runs plan/execute inline (backgrounded when dispatch-should-flatten returns false), and loops back to the dashboard after each action. Enables parallel phase work from one terminal. @@ -45,7 +45,7 @@ Display startup banner: {milestone_version} — {milestone_name} {phase_count} phases · {completed_count} complete - ✓ Discuss → inline ◆ Plan/Execute → inline (background on Codex) + ✓ Discuss → inline ◆ Plan/Execute → inline (background when FLATTEN=false) Dashboard auto-refreshes when background work is active. ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ``` @@ -222,10 +222,10 @@ Go to exit step. ### Compound Action (background + inline) -When the user selects a compound option, behavior depends on the runtime — the Plan Phase N / Execute Phase N handlers below resolve it via `gsd_run query config-get runtime`: +When the user selects a compound option, behavior depends on whether the runtime supports background dispatch of nesting-capable orchestrators — the Plan Phase N / Execute Phase N handlers below resolve it via `gsd_run query dispatch-should-flatten` (#1708): -- **On Codex:** **Spawn all background agents first** (plan/execute) — dispatch them in parallel using the Plan Phase N / Execute Phase N handlers below — then run verification actions, then run the inline discuss; the background agents continue while you verify/discuss. -- **On Claude Code or any other non-Codex runtime:** run the chosen plan/execute step(s) **inline** via their handlers below (in order), then run verification actions, then run the inline discuss. There is no overlap. +- **If `FLATTEN` is `false` (the host can background a nesting-capable orchestrator — e.g. codex, cursor):** **Spawn all background agents first** (plan/execute) — dispatch them in parallel using the Plan Phase N / Execute Phase N handlers below — then run verification actions, then run the inline discuss; the background agents continue while you verify/discuss. +- **Otherwise (`FLATTEN` is `true` — run inline):** run the chosen plan/execute step(s) **inline** via their handlers below (in order), then run verification actions, then run the inline discuss. There is no overlap. Inline verification: @@ -254,13 +254,13 @@ After discuss completes, loop back to dashboard step. ### Plan Phase N -Planning runs autonomously. **First resolve the runtime.** Background dispatch is only safe on a runtime where a backgrounded agent can still nest the pipeline's subagents (plan-checker / worktree executors / verifier). Among supported runtimes only **Codex** (`spawn_agent`) can do this; Claude Code's backgrounded agents have no `Agent`/`Task` tool, and every other runtime either prohibits nested subagents or disables them by default. So run **inline** everywhere except Codex, which is dispatched in the background. +Planning runs autonomously. **First resolve whether background dispatch is safe.** Background dispatch is only safe on a runtime where a backgrounded agent can still nest the pipeline's subagents (plan-checker / worktree executors / verifier). This is determined from the documentation-sourced dispatch capability in the registry (#1708); Claude Code's backgrounded agents have no `Agent`/`Task` tool, and every other runtime either prohibits nested subagents or disables them by default. So run **inline** everywhere except where `dispatch-should-flatten` returns `false`. ```bash -RUNTIME=$(gsd_run query config-get runtime --default claude --raw 2>/dev/null || echo "claude") +FLATTEN=$(gsd_run query dispatch-should-flatten --raw 2>/dev/null || echo "true") ``` -**If `RUNTIME` is `codex`:** Spawn a background agent that delegates to the Skill pipeline with any configured flags: +**If `FLATTEN` is `false`:** Spawn a background agent that delegates to the Skill pipeline with any configured flags: ``` Agent( @@ -282,7 +282,7 @@ Important: You are running in the background. Do NOT use AskUserQuestion — mak ) ``` -> **ORCHESTRATOR RULE — CODEX RUNTIME**: After calling Agent() above with `run_in_background=true`, do NOT do any planning work for this phase independently. Return to the dashboard immediately and wait for the background agent to report back. Only resume planning-related work when the subagent result is available. +> **ORCHESTRATOR RULE — BACKGROUND DISPATCH**: After calling Agent() above with `run_in_background=true`, do NOT do any planning work for this phase independently. Return to the dashboard immediately and wait for the background agent to report back. Only resume planning-related work when the subagent result is available. Display: @@ -292,7 +292,7 @@ Display: Loop back to dashboard step. -**Otherwise (Claude Code or any other non-Codex runtime):** Run plan inline so the plan-checker and quality gates actually run — do NOT wrap it in `Agent(run_in_background=true, …)`: +**Otherwise (`FLATTEN` is `true` — run inline):** Run plan inline so the plan-checker and quality gates actually run — do NOT wrap it in `Agent(run_in_background=true, …)`: ``` Skill(skill="gsd-plan-phase", args="{N} --auto {manager_flags.plan}") @@ -308,13 +308,13 @@ Then loop back to dashboard step. ### Execute Phase N -Execution runs autonomously. **First resolve the runtime.** Background dispatch is only safe on a runtime where a backgrounded agent can still nest the pipeline's subagents (plan-checker / worktree executors / verifier). Among supported runtimes only **Codex** (`spawn_agent`) can do this; Claude Code's backgrounded agents have no `Agent`/`Task` tool, and every other runtime either prohibits nested subagents or disables them by default. So run **inline** everywhere except Codex, which is dispatched in the background. +Execution runs autonomously. **First resolve whether background dispatch is safe.** Background dispatch is only safe on a runtime where a backgrounded agent can still nest the pipeline's subagents (plan-checker / worktree executors / verifier). This is determined from the documentation-sourced dispatch capability in the registry (#1708); Claude Code's backgrounded agents have no `Agent`/`Task` tool, and every other runtime either prohibits nested subagents or disables them by default. So run **inline** everywhere except where `dispatch-should-flatten` returns `false`. ```bash -RUNTIME=$(gsd_run query config-get runtime --default claude --raw 2>/dev/null || echo "claude") +FLATTEN=$(gsd_run query dispatch-should-flatten --raw 2>/dev/null || echo "true") ``` -**If `RUNTIME` is `codex`:** Spawn a background agent that delegates to the Skill pipeline with any configured flags: +**If `FLATTEN` is `false`:** Spawn a background agent that delegates to the Skill pipeline with any configured flags: ``` Agent( @@ -336,7 +336,7 @@ Important: You are running in the background. Do NOT use AskUserQuestion — mak ) ``` -> **ORCHESTRATOR RULE — CODEX RUNTIME**: After calling Agent() above with `run_in_background=true`, do NOT do any execution work for this phase independently. Return to the dashboard immediately and wait for the background agent to report back. Only resume execution-related work when the subagent result is available. +> **ORCHESTRATOR RULE — BACKGROUND DISPATCH**: After calling Agent() above with `run_in_background=true`, do NOT do any execution work for this phase independently. Return to the dashboard immediately and wait for the background agent to report back. Only resume execution-related work when the subagent result is available. Display: @@ -346,7 +346,7 @@ Display: Loop back to dashboard step. -**Otherwise (Claude Code or any other non-Codex runtime):** Run execute inline so worktree isolation and the verifier actually run — do NOT wrap it in `Agent(run_in_background=true, …)`: +**Otherwise (`FLATTEN` is `true` — run inline):** Run execute inline so worktree isolation and the verifier actually run — do NOT wrap it in `Agent(run_in_background=true, …)`: ``` Skill(skill="gsd-execute-phase", args="{N} {manager_flags.execute}") diff --git a/package.json b/package.json index d99ca54ac..b5e44b550 100644 --- a/package.json +++ b/package.json @@ -94,9 +94,8 @@ "pretest:coverage": "npm run build:lib && npm run lint:skill-deps", "lint": "eslint . --cache --cache-location node_modules/.cache/eslint/", "lint:fix": "eslint . --fix", - "lint:ci": "npm run lint && npm run lint:skill-deps && node scripts/lint-test-file-count.cjs && node scripts/lint-command-contract.cjs && node scripts/lint-pr-check-project-dir.cjs && npm run lint:legacy-name && node scripts/lint-regression-test-names.cjs && node scripts/lint-windows-test-portability.cjs && node scripts/lint-allow-test-rule-refs.cjs && node scripts/lint-resolution-provenance.cjs", + "lint:ci": "npm run lint && npm run lint:skill-deps && node scripts/lint-test-file-count.cjs && node scripts/lint-command-contract.cjs && node scripts/lint-pr-check-project-dir.cjs && npm run lint:legacy-name && node scripts/lint-regression-test-names.cjs && node scripts/lint-allow-test-rule-refs.cjs && node scripts/lint-resolution-provenance.cjs", "lint:allow-test-rule-refs": "node scripts/lint-allow-test-rule-refs.cjs", - "lint:windows-test-portability": "node scripts/lint-windows-test-portability.cjs", "lint:regression-names": "node scripts/lint-regression-test-names.cjs", "lint:descriptions": "node scripts/lint-descriptions.cjs", "lint:skill-deps": "node scripts/lint-skill-deps.cjs", diff --git a/scripts/ci-test-scope.cjs b/scripts/ci-test-scope.cjs index fa78344b4..d4b2ac6cf 100644 --- a/scripts/ci-test-scope.cjs +++ b/scripts/ci-test-scope.cjs @@ -118,6 +118,7 @@ const RULES = [ tests: [ 'tests/semver-compare.test.cjs', 'tests/bug-10-semver-policy-consolidation.test.cjs', + 'tests/golden-install-parity.test.cjs', // any src/installer change can alter emitted install artifacts → re-verify golden install parity (drift guard) ], }, { @@ -134,6 +135,7 @@ const RULES = [ 'tests/install-path-detection.test.cjs', 'tests/release-tarball-smoke.install.test.cjs', 'tests/runtime-artifact-layout.test.cjs', + 'tests/golden-install-parity.test.cjs', // any src/installer change can alter emitted install artifacts → re-verify golden install parity (drift guard) ], }, { @@ -212,17 +214,44 @@ const RULES = [ ], }, { - name: 'configuration', - match: path => ['config', 'configuration', 'model-catalog', 'model-profile'].some(k => path.includes(k)), + name: 'configuration', + match: path => ['config', 'configuration', 'model-catalog', 'model-profile'].some(k => path.includes(k)), + tests: [ + 'tests/config.test.cjs', + 'tests/config-get-default.test.cjs', + 'tests/configuration-migrate-config.test.cjs', + 'tests/model-catalog-runtime-defaults.test.cjs', + 'tests/model-profiles.test.cjs', + ], + }, + { + // ADR-1703 portability lint surface. Editing a rule, the shared vocab/guard + // helpers, or the eslint config that wires them must re-run the rule suites + // + the disable-ban. The disable-ban also scans bin/install.js and + // scripts/build-hooks.js (the Phase 6 glob-expansion surface), so changes + // to those files re-run it too. + name: 'portability lint rules (ADR-1703)', + match: path => path.startsWith('eslint-rules/') || + path === 'eslint.config.mjs' || + path === 'bin/install.js' || + path === 'scripts/build-hooks.js', tests: [ - 'tests/config.test.cjs', - 'tests/config-get-default.test.cjs', - 'tests/configuration-migrate-config.test.cjs', - 'tests/model-catalog-runtime-defaults.test.cjs', - 'tests/model-profiles.test.cjs', + 'tests/portability-rule-disable-ban.test.cjs', + 'tests/portability-vocab-drift.test.cjs', + // All nine RuleTester suites (P1–P6) — editing any rule / the shared + // vocab+guard helpers / the eslint config re-runs the full rule family. + 'tests/no-path-literal-in-assert.rule.test.cjs', + 'tests/no-posix-mode-bit-assert.rule.test.cjs', + 'tests/no-unguarded-nonportable-exec.rule.test.cjs', + 'tests/no-crlf-fragile-split.rule.test.cjs', + 'tests/no-hardcoded-tmp.rule.test.cjs', + 'tests/no-bare-npm-exec.rule.test.cjs', + 'tests/require-userprofile-with-home.rule.test.cjs', + 'tests/normalize-path-in-content.rule.test.cjs', + 'tests/require-fs-op-fallback.rule.test.cjs', ], }, -]; + ]; function usage() { return [ @@ -329,7 +358,7 @@ function classify(files) { // Determine if this file is product/pipeline code. // docs/ and root-level .md files are intentionally excluded. if ( - ['bin/', 'src/', 'gsd-core/', 'agents/', 'commands/', 'hooks/', 'tests/', 'scripts/'].some(p => file.startsWith(p)) || + ['bin/', 'src/', 'gsd-core/', 'agents/', 'commands/', 'hooks/', 'tests/', 'scripts/', 'eslint-rules/'].some(p => file.startsWith(p)) || file === 'package.json' || file === 'package-lock.json' || (file.startsWith('tsconfig') && file.endsWith('.json')) || file.startsWith('.github/rulesets/') diff --git a/scripts/lint-allow-test-rule-refs.allowlist.json b/scripts/lint-allow-test-rule-refs.allowlist.json index 55e98eaac..4bee3ccdd 100644 --- a/scripts/lint-allow-test-rule-refs.allowlist.json +++ b/scripts/lint-allow-test-rule-refs.allowlist.json @@ -313,7 +313,6 @@ "tests/verify-test-quality.test.cjs :: source-text-is-the-product", "tests/verify-work-auto-transition.test.cjs :: source-text-is-the-product", "tests/windows-robustness.test.cjs :: source-text-is-the-product", - "tests/windows-test-parity-guard.test.cjs :: structural-regression-guard", "tests/workflow-compat.test.cjs :: source-text-is-the-product", "tests/workflow-guard-registration.test.cjs :: structural-regression-guard", "tests/workflow-maintainer-skip.test.cjs :: source-text-is-the-product", diff --git a/scripts/lint-test-file-count.allowlist.json b/scripts/lint-test-file-count.allowlist.json index 6c83710ad..dcf03c2b1 100644 --- a/scripts/lint-test-file-count.allowlist.json +++ b/scripts/lint-test-file-count.allowlist.json @@ -142,9 +142,10 @@ "install-regressions.test.cjs", "install-runtime-artifacts.test.cjs", "install-update-marker.test.cjs", + "install-write-confinement.test.cjs", "install.test.cjs" ], - "issue": "TBD" + "issue": "1679" }, "validate": { "files": [ @@ -184,6 +185,14 @@ "fix-1464-docs-manifest-validation.test.cjs" ], "issue": "1496" + }, + "host-integration": { + "files": [ + "host-integration.test.cjs", + "host-integration-validator-parity.test.cjs", + "host-integration-descriptors.test.cjs" + ], + "issue": "1684" } } } diff --git a/scripts/lint-windows-test-portability.cjs b/scripts/lint-windows-test-portability.cjs deleted file mode 100644 index 6994a447c..000000000 --- a/scripts/lint-windows-test-portability.cjs +++ /dev/null @@ -1,178 +0,0 @@ -'use strict'; - -/** - * lint-windows-test-portability.cjs — flag tests that combine chmod exec-bit - * with bare sh/bash -c without a platform guard. - * - * ## Why - * - * Windows Git Bash (msys2) does not honour Node's chmod exec bit for - * PATH-executing extension-less scripts. A test that (a) makes a fixture - * executable via chmodSync and (b) runs it with `sh -c`/`bash -c` will pass - * on Mac/Linux but fail only in the CI `test (windows-latest, *)` / - * `full test (windows-latest, *)` lanes, producing a hard-to-diagnose - * false-negative gate. See CONTEXT.md → DEFECT.WINDOWS-TEST-PORTABILITY. - * - * ## What this enforces - * - * For every file in tests/**\/*.test.cjs (recursive, excluding node_modules): - * - makesExecutable: contains chmodSync?( with an exec-bit octal literal - * - shellDashC: contains a sh/bash -c invocation (array form or string literal) - * - guarded: contains a process.platform / os.platform() / win32 / isWindows guard - * - optOut: contains the literal `windows-portability-ok` - * VIOLATION = makesExecutable && shellDashC && !guarded && !optOut - * - * ## Remediation - * - * Gate the bare-command execution with `if (process.platform !== 'win32')`, - * or invoke via an explicit interpreter (`sh `), or annotate - * `// windows-portability-ok: `. - * - * ## Export contract (for unit tests) - * - * When required as a module (`require.main !== module`) this file exports - * `scanContent(source)` → { makesExecutable, shellDashC, guarded, optOut, violation }. - */ - -const fs = require('fs'); -const path = require('path'); - -// ─── Detection regexes ────────────────────────────────────────────────────── - -/** - * Match chmod/chmodSync( calls with an octal mode literal whose exec bits are - * set, e.g. `fs.chmodSync(p, 0o755)` or `chmod(file, 0o111)`. - */ -const CHMOD_RE = /chmod(?:Sync)?\s*\([^,;]+,\s*0o([0-7]{3})\b/g; - -/** - * Array form: execFileSync/spawnSync/exec* with 'sh' or 'bash' (optionally - * prefixed) as the first arg and '-c' as an element of the args array. - * e.g. execFileSync('bash', ['-c', ...]) or spawnSync('/bin/sh', ['-c', ...]) - */ -const SHELL_ARRAY_RE = - /(?:execFile(?:Sync)?|spawnSync|spawn|exec)\s*\(\s*['"`](?:\/(?:usr\/)?bin\/)?(?:bash|sh)['"`]\s*,\s*\[[^\]]*['"]-c['"]/; - -/** - * String-literal form: any string containing `bash -c` or `sh -c`. - */ -const SHELL_STRING_RE = /['"`][^'"`\n]*(?:bash|sh)\s+-c[^'"`\n]*['"`]/; - -/** Platform guard presence. */ -const GUARD_RE = /process\.platform|os\.platform\s*\(|\bwin32\b|\bisWindows\b/; - -/** Opt-out annotation. */ -const OPT_OUT_RE = /windows-portability-ok/; - -// ─── Pure scanning function (exported for unit tests) ──────────────────────── - -/** - * Scan a single file's source text and return detection flags. - * - * @param {string} source - The file contents as a string. - * @returns {{ makesExecutable: boolean, shellDashC: boolean, guarded: boolean, optOut: boolean, violation: boolean }} - */ -function scanContent(source) { - // Reset stateful regex before use. - CHMOD_RE.lastIndex = 0; - - let makesExecutable = false; - let match; - while ((match = CHMOD_RE.exec(source)) !== null) { - const oct = match[1]; - if ((parseInt(oct, 8) & 0o111) !== 0) { - makesExecutable = true; - break; - } - } - - const shellDashC = SHELL_ARRAY_RE.test(source) || SHELL_STRING_RE.test(source); - const guarded = GUARD_RE.test(source); - const optOut = OPT_OUT_RE.test(source); - const violation = makesExecutable && shellDashC && !guarded && !optOut; - - return { makesExecutable, shellDashC, guarded, optOut, violation }; -} - -// ─── Filesystem walker ─────────────────────────────────────────────────────── - -/** - * Recursively collect all *.test.cjs files under `dir`, excluding node_modules. - * - * @param {string} dir - * @param {string[]} [acc] - * @returns {string[]} - */ -function collectTestFiles(dir, acc) { - acc = acc || []; - let entries; - try { - entries = fs.readdirSync(dir, { withFileTypes: true }); - } catch { - return acc; - } - for (const entry of entries) { - if (entry.name === 'node_modules') continue; - const full = path.join(dir, entry.name); - if (entry.isDirectory()) { - collectTestFiles(full, acc); - } else if (entry.isFile() && entry.name.endsWith('.test.cjs')) { - acc.push(full); - } - } - return acc; -} - -// ─── Main ──────────────────────────────────────────────────────────────────── - -function main() { - const ROOT = path.join(__dirname, '..'); - const TESTS_DIR = path.join(ROOT, 'tests'); - - const files = collectTestFiles(TESTS_DIR); - const violations = []; - - for (const file of files) { - let source; - try { - source = fs.readFileSync(file, 'utf8'); - } catch { - continue; - } - const { violation } = scanContent(source); - if (violation) { - const rel = path.relative(ROOT, file).replace(/\\/g, '/'); - violations.push(rel); - } - } - - if (violations.length > 0) { - for (const rel of violations) { - process.stderr.write( - `${rel}: chmod-executable + sh/bash -c with no platform guard\n`, - ); - } - process.stderr.write( - '\nWindows Git Bash does not honor Node\'s chmod exec bit for ' + - 'PATH-executing extension-less scripts ' + - '(CONTEXT.md → DEFECT.WINDOWS-TEST-PORTABILITY). ' + - 'Gate the bare-command execution with ' + - '`if (process.platform !== \'win32\')`, or invoke via an explicit ' + - 'interpreter (`sh `), or annotate ' + - '`// windows-portability-ok: `.\n', - ); - process.exitCode = 1; - } else { - console.log( - `ok lint-windows-test-portability: ${files.length} file(s) scanned, no violations`, - ); - } -} - -// ─── Module boundary ───────────────────────────────────────────────────────── - -if (require.main === module) { - main(); -} else { - module.exports = { scanContent }; -} diff --git a/scripts/mutation-matrix.cjs b/scripts/mutation-matrix.cjs index 18dad2b96..2f012d493 100644 --- a/scripts/mutation-matrix.cjs +++ b/scripts/mutation-matrix.cjs @@ -30,10 +30,52 @@ */ const { execFileSync } = require('child_process'); -const { readFileSync } = require('fs'); +const fs = require('fs'); const { ExitError, runMain } = require('./lib/cli-exit.cjs'); +// ── Resilient stdin reader ──────────────────────────────────────────────────── +// On macOS, libuv sets the stdin pipe fd to non-blocking mode. A synchronous +// readFileSync(process.stdin.fd) can therefore throw EAGAIN ("resource +// temporarily unavailable") when the writer hasn't yet filled the pipe — this +// is intermittent under heavy CI shard load and causes a spurious status 2 +// exit. We work around it by calling fs.readSync in a loop and retrying on +// EAGAIN with a 1 ms synchronous pause (Atomics.wait on a fresh SharedArrayBuffer +// — no hot spin, no real-clock dependency, works under --experimental-vm-modules). +/** + * Read all of stdin synchronously, retrying on EAGAIN. + * + * @returns {string} UTF-8 decoded full stdin content. + */ +function readStdinSync() { + const BUF_SIZE = 64 * 1024; // 64 KB chunks + const buf = Buffer.allocUnsafe(BUF_SIZE); + const chunks = []; + + for (;;) { + let bytesRead; + try { + bytesRead = fs.readSync(process.stdin.fd, buf, 0, BUF_SIZE, null); + } catch (err) { + if (err.code === 'EAGAIN') { + // Non-blocking pipe not yet ready — yield for ~1 ms then retry. + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 1); + continue; + } + if (err.code === 'EOF') { + break; + } + throw err; + } + if (bytesRead === 0) { + break; // Clean EOF + } + chunks.push(Buffer.from(buf.slice(0, bytesRead))); + } + + return Buffer.concat(chunks).toString('utf8'); +} + // ── Per-module mutation score ratchet ───────────────────────────────────────── // ADR-456 / issue #1187: every covered module declares a minScore floor. // @@ -195,7 +237,7 @@ function resolveChangedFiles(args) { // When --base is absent AND stdin is not a TTY (isTTY is falsy / undefined), // read a newline-delimited file list from stdin. if (!args.base && process.stdin.isTTY !== true) { - const raw = readFileSync(process.stdin.fd, 'utf8'); + const raw = readStdinSync(); return raw.split('\n').map(l => l.trim()).filter(Boolean); } @@ -318,6 +360,6 @@ function resolveMutationBreak(raw) { // Export internals for programmatic use (tests/mutation-matrix-ratchet.test.cjs). // The require.main guard prevents main() from running when this file is require()d. -module.exports = { COVERED, TARGET_MUTATION_SCORE, resolveMutationBreak }; +module.exports = { COVERED, TARGET_MUTATION_SCORE, resolveMutationBreak, readStdinSync }; if (require.main === module) runMain(main); diff --git a/scripts/prompt-injection-scan.sh b/scripts/prompt-injection-scan.sh index 3f9ff9fb9..b94460efe 100755 --- a/scripts/prompt-injection-scan.sh +++ b/scripts/prompt-injection-scan.sh @@ -94,6 +94,14 @@ ALLOWLIST=( # real injection payloads to prove the validator rejects them. See # DEFECT.PROMPT-INJECTION-SCAN-COLLISION in CONTEXT.md. 'tests/windsurf-conversion.test.cjs' + # RuleTester fixtures for the local/no-unguarded-nonportable-exec ESLint rule + # contain shell-exec command strings (exec("sh -c …"), execFileSync('bash',['-c',…])) + # as test DATA the rule must lint — not attack vectors. ADR-1703 Phase 3 (#1720). + 'tests/no-unguarded-nonportable-exec.rule.test.cjs' + # RuleTester fixtures for the local/no-bare-npm-exec ESLint rule contain npm + # exec command strings (execFileSync('npm', ['install'])) as test DATA the rule + # must lint — not attack vectors. ADR-1703 Phase 4 (#1726). + 'tests/no-bare-npm-exec.rule.test.cjs' ) is_allowlisted() { diff --git a/src/capability-lifecycle.cts b/src/capability-lifecycle.cts index c6a258334..fab97ccbe 100644 --- a/src/capability-lifecycle.cts +++ b/src/capability-lifecycle.cts @@ -83,8 +83,9 @@ const lockMod = require('./capability-lock.cjs') as { _setLockProbes: (probes: Partial<{ isPidAlive: (pid: number) => boolean; getProcessStartTime: (pid: number) => string | null }>) => void; _resetLockProbes: () => void; }; -const { platformWriteSync } = require('./shell-command-projection.cjs') as { +const { platformWriteSync, retryRenameSync } = require('./shell-command-projection.cjs') as { platformWriteSync: (filePath: string, content: string) => void; + retryRenameSync: (fromPath: string, toPath: string) => void; }; // #1463: numeric major.minor.patch comparison for the outdated check (the SAME compare the resolver // and capability list use). -1 (ab). @@ -506,14 +507,14 @@ function promoteStagingToFinal( ? path.join(parent, backupName) // CONC-3: a random nonce in the unnamed-branch backup name prevents same-ms cross-process collision. : path.join(parent, newBackupName(path.basename(finalDir))); - fs.renameSync(finalDir, backupDir); + retryRenameSync(finalDir, backupDir); // DUR-3: fsync the parent dir so the old→backup rename is durable BEFORE the second rename — // a crash here must not lose the backup (the only recovery path for reconcile). fsyncDir(parent); try { - fs.renameSync(stagingDir, finalDir); + retryRenameSync(stagingDir, finalDir); } catch (err) { - try { fs.renameSync(backupDir, finalDir); } catch { /* best-effort restore */ } + try { retryRenameSync(backupDir, finalDir); } catch { /* best-effort restore */ } throw err; } // DUR-3: fsync the parent dir again so the staging→final rename is durable too. @@ -521,7 +522,7 @@ function promoteStagingToFinal( return { backupDir }; } fs.mkdirSync(parent, { recursive: true }); - fs.renameSync(stagingDir, finalDir); + retryRenameSync(stagingDir, finalDir); fsyncDir(parent); // DUR-3: durable fresh-install promotion. return { backupDir: null }; } @@ -1535,8 +1536,8 @@ function reconcileCapabilities(opts: { runtimeDir: string; scope?: 'global' | 'p // - crash after step (a): backup still present + `_pending` still references it → retry. // - crash after step (b): old bundle live at finalDir; only the aside copy leaks → swept. const discard = `${finalDir}.discard-${process.pid}-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`; - if (fs.existsSync(finalDir)) fs.renameSync(finalDir, discard); // (a) set the new dir aside - fs.renameSync(backupDir, finalDir); // (b) restore the old bundle + if (fs.existsSync(finalDir)) retryRenameSync(finalDir, discard); // (a) set the new dir aside + retryRenameSync(backupDir, finalDir); // (b) restore the old bundle fsyncDir(root); // make the restore durable try { fs.rmSync(discard, { recursive: true, force: true }); } catch { /* swept later */ } restored = true; diff --git a/src/capability-lock.cts b/src/capability-lock.cts index c611dd370..bf3cb2ab8 100644 --- a/src/capability-lock.cts +++ b/src/capability-lock.cts @@ -42,12 +42,13 @@ import crypto from 'node:crypto'; const ledgerMod = require('./capability-ledger.cjs') as { readSmallRegularFile: (filePath: string, maxBytes: number) => string | null; }; -const { execTool } = require('./shell-command-projection.cjs') as { +const { execTool, retryRenameSync } = require('./shell-command-projection.cjs') as { execTool: ( program: string, args: string[], opts?: { cwd?: string; env?: Record; timeout?: number }, ) => { exitCode: number; stdout: string; stderr: string; signal: NodeJS.Signals | null; error: Error | null }; + retryRenameSync: (fromPath: string, toPath: string) => void; }; /* eslint-enable @typescript-eslint/no-require-imports */ @@ -494,7 +495,7 @@ function acquireLock(lockPath: string, opts?: { maxAttempts?: number; waitForFre // Steal atomically (only one racer can rename the inode). const stolen = `${lockPath}.stale-${process.pid}-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`; - try { fs.renameSync(lockPath, stolen); } catch { return null; } // another process won the steal + try { retryRenameSync(lockPath, stolen); } catch { return null; } // another process won the steal try { fs.rmSync(stolen, { force: true }); } catch { /* best-effort */ } if (attempt + 1 < maxAttempts) lockBackoff(); } diff --git a/src/capability-source.cts b/src/capability-source.cts index 32c267585..eee1c3023 100644 --- a/src/capability-source.cts +++ b/src/capability-source.cts @@ -34,6 +34,7 @@ const shellSeam = require('./shell-command-projection.cjs') as { execGit: (args: string[], opts?: { cwd?: string; timeout?: number }) => SpawnResult; execNpm: (args: string[], opts?: { cwd?: string; timeout?: number }) => SpawnResult; execTool: (program: string, args: string[], opts?: { cwd?: string; timeout?: number }) => SpawnResult; + retryRenameSync: (fromPath: string, toPath: string) => void; }; // eslint-disable-next-line @typescript-eslint/no-require-imports @@ -752,16 +753,16 @@ function stageValidated(opts: { // lives in capability-lifecycle.cjs and uses promote:false above.) if (fs.existsSync(finalDir)) { const backupDir = `${finalDir}.old-${process.pid}-${Date.now()}`; - fs.renameSync(finalDir, backupDir); + shellSeam.retryRenameSync(finalDir, backupDir); try { - fs.renameSync(stagingDir, finalDir); + shellSeam.retryRenameSync(stagingDir, finalDir); } catch (err) { - try { fs.renameSync(backupDir, finalDir); } catch { /* best-effort restore */ } + try { shellSeam.retryRenameSync(backupDir, finalDir); } catch { /* best-effort restore */ } throw err; } try { fs.rmSync(backupDir, { recursive: true, force: true }); } catch { /* best-effort */ } } else { - fs.renameSync(stagingDir, finalDir); + shellSeam.retryRenameSync(stagingDir, finalDir); } const version = typeof cap['version'] === 'string' ? cap['version'] : ''; diff --git a/src/cli-skew-check.cts b/src/cli-skew-check.cts new file mode 100644 index 000000000..d21d3c460 --- /dev/null +++ b/src/cli-skew-check.cts @@ -0,0 +1,47 @@ +'use strict'; + +/** + * cli-skew-check.cts — CLI version-skew detection (#1754). + * + * Pure function: compares the resolved gsd-tools.cjs path to the project root. + * If the resolved CLI is OUTSIDE the project root while a project-local install + * EXISTS, returns a warning string (the caller writes it to stderr). Non-blocking. + * + * Catches the shadowing scenario from #1748: a stale global canary CLI (e.g. + * from the retired @gsd-build/sdk) shadowing the project-local GSD install. + * + * The function is PURE (no I/O) — the caller provides the resolved path, the + * project root, and whether a project-local install exists. This makes it + * trivially testable without filesystem setup. + */ + +import path from 'node:path'; + +/** + * Check for CLI version skew. + * + * @param opts.resolvedPath - The absolute path of the running gsd-tools.cjs (__filename). + * @param opts.projectRoot - The project root (from findProjectRoot), or null if no project. + * @param opts.projectLocalExists - Whether a project-local gsd-tools.cjs exists. + * @returns A warning string if skew is detected, or null if no skew. + */ +export function checkCliSkew(opts: { + resolvedPath: string; + projectRoot: string | null; + projectLocalExists: boolean; +}): string | null { + const { resolvedPath, projectRoot, projectLocalExists } = opts; + + // No project context or no project-local install → no skew possible. + if (!projectRoot || !projectLocalExists) return null; + + // If the resolved CLI is under the project root, it IS a project-local install. + const rel = path.relative(projectRoot, resolvedPath); + if (!rel.startsWith('..')) return null; + + // Resolved CLI is outside project root while a project-local install exists → SKEW. + const hint = resolvedPath.includes('@gsd-build') + ? ' If @gsd-build/sdk: npm uninstall -g @gsd-build/sdk' + : ''; + return `⚠ GSD: ${resolvedPath} may shadow project-local GSD.${hint}`; +} diff --git a/src/host-integration.cts b/src/host-integration.cts new file mode 100644 index 000000000..e428cafd0 --- /dev/null +++ b/src/host-integration.cts @@ -0,0 +1,530 @@ +'use strict'; + +/** + * Host Integration module — ADR-1239 Phase A. + * + * Pure, additive, no-I/O module providing a closed vocabulary for host + * integration axes, degradation ladder, profile classification, and + * capability negotiation. + * + * The SINGLE source of truth for integration axes and degradation levels. + * All functions are pure (no side effects, no I/O). + * + * Per-CLI sourced axis VALUES (with citations) live in docs/reference/host-integration-capability-matrix.md — every value is documented or explicitly 'undocumented'. + */ + +// --------------------------------------------------------------------------- +// Protocol version +// --------------------------------------------------------------------------- + +const PROTOCOL_VERSION = 1; + +// --------------------------------------------------------------------------- +// Undocumented sentinel — fail-closed when a host omits CLI docs for an axis +// --------------------------------------------------------------------------- + +/** + * Sentinel value used when a host descriptor's CLI docs do not state a value + * for an axis. It VALIDATES (accepted by the validator) but NEVER propagates + * into effective axes — it fails closed exactly like an unknown/missing value. + * + * Do NOT add to HOST_INTEGRATION_AXES (which is the documented vocabulary). + */ +const UNDOCUMENTED = 'undocumented'; + +// --------------------------------------------------------------------------- +// Closed vocabulary — axes and interface points +// --------------------------------------------------------------------------- + +const HOST_INTEGRATION_AXES = Object.freeze({ + embeddingMode: Object.freeze(['imperative', 'declarative'] as const), + commandSurface: Object.freeze(['slash-file', 'slash-programmatic', 'slash-toml', 'palette', 'prose-only'] as const), + modelMode: Object.freeze(['active', 'passive'] as const), + hookBus: Object.freeze(['host', 'engine', 'none'] as const), + stateIO: Object.freeze(['filesystem', 'sandboxed-storage', 'session-log-append'] as const), + transport: Object.freeze(['mcp', 'native-extension'] as const), + runtime: Object.freeze(['node', 'bun', 'sandboxed-web', 'python', 'go', 'rust', 'electron', 'other'] as const), + subagentToolkit: Object.freeze(['full', 'read-only'] as const), +}); + +const INTERFACE_POINTS = Object.freeze(['command', 'dispatch', 'model', 'hooks', 'state', 'artifact'] as const); + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type EmbeddingMode = 'imperative' | 'declarative'; +type CommandSurface = 'slash-file' | 'slash-programmatic' | 'slash-toml' | 'palette' | 'prose-only'; +type ModelMode = 'active' | 'passive'; +type HookBus = 'host' | 'engine' | 'none'; +type StateIO = 'filesystem' | 'sandboxed-storage' | 'session-log-append'; +type Transport = 'mcp' | 'native-extension'; +type HostRuntime = 'node' | 'bun' | 'sandboxed-web' | 'python' | 'go' | 'rust' | 'electron' | 'other'; +type SubagentToolkit = 'full' | 'read-only'; +type DegradationLevel = 'full' | 'degraded' | 'absent'; +type InterfacePoint = 'command' | 'dispatch' | 'model' | 'hooks' | 'state' | 'artifact'; + +interface DispatchCapability { + namedDispatch: boolean; + nested: boolean; + maxDepth: number; + background: boolean; + subagentToolkit: SubagentToolkit; + backgroundDispatch: boolean; +} + +interface HostIntegrationAxes { + embeddingMode: EmbeddingMode; + commandSurface: CommandSurface; + dispatch: DispatchCapability; + modelMode: ModelMode; + hookBus: HookBus; + stateIO: StateIO; + transport: Transport; + runtime: HostRuntime; +} + +interface DegradationResult { + level: DegradationLevel; + fallback: string; + unknown?: boolean; +} + +// --------------------------------------------------------------------------- +// Profile baselines +// --------------------------------------------------------------------------- + +// Fail-closed floor: the most restrictive known value per axis, injected when a host omits an axis (degrade-closed, never assume capability). +const SAFE_DEFAULTS: HostIntegrationAxes = { + embeddingMode: 'declarative', + commandSurface: 'prose-only', + dispatch: { namedDispatch: false, nested: false, maxDepth: 0, background: false, subagentToolkit: 'read-only', backgroundDispatch: false }, + modelMode: 'passive', + hookBus: 'none', + stateIO: 'session-log-append', + transport: 'mcp', + runtime: 'node', +}; + +const PROFILE_BASELINES: Readonly> = + Object.freeze({ + 'programmatic-cli': Object.freeze({ + embeddingMode: 'imperative', + commandSurface: 'slash-file', + dispatch: Object.freeze({ namedDispatch: true, nested: true, maxDepth: -1, background: true, subagentToolkit: 'full', backgroundDispatch: true }), + modelMode: 'passive', + hookBus: 'host', + stateIO: 'filesystem', + transport: 'mcp', + runtime: 'node', + } as HostIntegrationAxes), + 'declarative-cli': Object.freeze({ + embeddingMode: 'declarative', + commandSurface: 'slash-file', + dispatch: Object.freeze({ namedDispatch: true, nested: false, maxDepth: 1, background: false, subagentToolkit: 'full', backgroundDispatch: false }), + modelMode: 'passive', + hookBus: 'host', + stateIO: 'filesystem', + transport: 'mcp', + runtime: 'node', + } as HostIntegrationAxes), + 'ide': Object.freeze({ + embeddingMode: 'imperative', + commandSurface: 'palette', + dispatch: Object.freeze({ namedDispatch: true, nested: true, maxDepth: 5, background: true, subagentToolkit: 'full', backgroundDispatch: true }), + modelMode: 'active', + hookBus: 'engine', + stateIO: 'sandboxed-storage', + transport: 'mcp', + runtime: 'sandboxed-web', + } as HostIntegrationAxes), + }); + +// --------------------------------------------------------------------------- +// degradationFor — plain data-table lookup (NOT clever code) +// --------------------------------------------------------------------------- + +/** + * Look up the degradation level for a given interface point and partial axes. + * + * NEVER throws. Returns { level:'absent', fallback:'...', unknown:true } for + * any missing or unrecognised axis value. + */ +function degradationFor(point: InterfacePoint, axes: Partial): DegradationResult { + const UNKNOWN: DegradationResult = { + level: 'absent', + fallback: 'unknown capability — degraded closed', + unknown: true, + }; + + switch (point) { + case 'command': { + const cs = (axes as Record).commandSurface; + if (cs === 'slash-file' || cs === 'slash-programmatic') return { level: 'full', fallback: '' }; + if (cs === 'slash-toml' || cs === 'palette') return { level: 'degraded', fallback: 'toml/palette surface — limited command routing' }; + if (cs === 'prose-only') return { level: 'absent', fallback: 'AGENTS.md prose + skills menu' }; + return UNKNOWN; + } + + case 'dispatch': { + const d = (axes as Record).dispatch; + if (!d || typeof d !== 'object') return UNKNOWN; + const disp = d as Record; + if (disp.namedDispatch !== true || disp.maxDepth === 0) { + return { level: 'absent', fallback: 'single-agent inline / SDK sub-session' }; + } + // maxDepth < 0 means unbounded + const isUnbounded = typeof disp.maxDepth === 'number' && Number.isFinite(disp.maxDepth) && disp.maxDepth < 0; + const depth = (typeof disp.maxDepth === 'number' && Number.isFinite(disp.maxDepth)) ? disp.maxDepth : 0; + const isFullDepth = isUnbounded || (disp.nested === true && depth >= 2); + if (isFullDepth) { + // Fail-closed: return 'full' ONLY when subagentToolkit is explicitly 'full'; + // any other value (read-only, undocumented, unknown, missing) → degraded. + if (disp.subagentToolkit === 'full') { + return { level: 'full', fallback: '' }; + } + return { level: 'degraded', fallback: 'restricted/undocumented subagent toolkit — limited dispatch surface' }; + } + // flat (maxDepth===1) + return { level: 'degraded', fallback: 'flat dispatch — waves run inline' }; + } + + case 'model': { + const mm = (axes as Record).modelMode; + if (mm === 'active') return { level: 'full', fallback: '' }; + if (mm === 'passive') return { level: 'degraded', fallback: 'instruction-injection / per-agent model field' }; + return UNKNOWN; + } + + case 'hooks': { + const hb = (axes as Record).hookBus; + if (hb === 'host') return { level: 'full', fallback: '' }; + if (hb === 'engine') return { level: 'degraded', fallback: 'engine-owned bus' }; + if (hb === 'none') return { level: 'absent', fallback: 'rule-text instructions' }; + return UNKNOWN; + } + + case 'state': { + const si = (axes as Record).stateIO; + if (si === 'filesystem') return { level: 'full', fallback: '' }; + if (si === 'sandboxed-storage') return { level: 'degraded', fallback: 'sandboxed storage' }; + if (si === 'session-log-append') return { level: 'degraded', fallback: 'append-only session log' }; + return UNKNOWN; + } + + case 'artifact': { + const cs = (axes as Record).commandSurface; + if (cs === 'slash-file' || cs === 'slash-programmatic') return { level: 'full', fallback: '' }; + if (cs === 'slash-toml' || cs === 'prose-only') return { level: 'degraded', fallback: 'menu / @-only' }; + if (cs === 'palette') return { level: 'absent', fallback: 'palette + chat participant; skills become LM tools' }; + return UNKNOWN; + } + + default: + return UNKNOWN; + } +} + +// --------------------------------------------------------------------------- +// profileOf +// --------------------------------------------------------------------------- + +/** + * Classify a partial set of integration axes into a named profile. + * Returns null when no profile can be determined. + */ +function profileOf(axes: Partial): 'programmatic-cli' | 'declarative-cli' | 'ide' | null { + const a = axes as Record; + if (a.embeddingMode === 'imperative' && a.runtime === 'sandboxed-web') return 'ide'; + if (a.embeddingMode === 'imperative') return 'programmatic-cli'; + if (a.embeddingMode === 'declarative') return 'declarative-cli'; + return null; +} + +// --------------------------------------------------------------------------- +// EngineCapabilities + DEFAULT_ENGINE +// --------------------------------------------------------------------------- + +interface EngineCapabilities { + protocolVersion: number; + axes: HostIntegrationAxes; + known: typeof HOST_INTEGRATION_AXES; +} + +const DEFAULT_ENGINE: EngineCapabilities = { + protocolVersion: PROTOCOL_VERSION, + axes: { + embeddingMode: 'imperative', + commandSurface: 'slash-file', + dispatch: { namedDispatch: true, nested: true, maxDepth: -1, background: true, subagentToolkit: 'full', backgroundDispatch: true }, + modelMode: 'active', + hookBus: 'host', + stateIO: 'filesystem', + transport: 'mcp', + runtime: 'node', + }, + known: HOST_INTEGRATION_AXES, +}; + +// --------------------------------------------------------------------------- +// NegotiationResult +// --------------------------------------------------------------------------- + +interface NegotiationResult { + protocolVersion: number; + effective: HostIntegrationAxes; + points: Record; + warnings: string[]; +} + +// --------------------------------------------------------------------------- +// negotiateHostCapabilities +// --------------------------------------------------------------------------- + +/** + * Negotiate host integration capabilities against an engine. + * + * POST-CONDITION: every effective scalar axis value is in engine.known[axis]. + * effective never contains a value the host didn't declare AND the engine + * cannot drive. + * + * NEVER throws. Returns a fresh object each call (mutation-safe). + */ +function negotiateHostCapabilities( + host: Partial & { protocolVersion?: number }, + engine: EngineCapabilities = DEFAULT_ENGINE, +): NegotiationResult { + const warnings: string[] = []; + const h = host as Record; + // Warn if protocolVersion is present but not a finite number + if (h.protocolVersion !== undefined && (typeof h.protocolVersion !== 'number' || !Number.isFinite(h.protocolVersion))) { + warnings.push(`host protocolVersion is not a finite number — using engine version ${engine.protocolVersion}`); + } + const hostPV = (typeof h.protocolVersion === 'number' && Number.isFinite(h.protocolVersion)) ? h.protocolVersion : engine.protocolVersion; + const enginePV = engine.protocolVersion; + + // Warn if host declares a newer protocol version + if (hostPV > enginePV) { + warnings.push( + `host protocolVersion ${hostPV} newer than engine ${enginePV} — capabilities beyond version ${enginePV} not trusted`, + ); + } + + // --------------------------------------------------------------------------- + // Helper: negotiate a single scalar axis + // --------------------------------------------------------------------------- + function negotiateScalar( + axis: K, + ): (typeof HOST_INTEGRATION_AXES)[K][number] { + type V = (typeof HOST_INTEGRATION_AXES)[K][number]; + const knownValues: ReadonlyArray = engine.known[axis]; + const hostVal = h[axis] as V | undefined; + const engineVal = engine.axes[axis as keyof HostIntegrationAxes] as V; + const safeDefault = SAFE_DEFAULTS[axis as keyof HostIntegrationAxes] as V; + + if (hostVal === undefined || hostVal === null) { + // Host did not declare this axis + warnings.push(`host did not declare '${axis}'`); + return safeDefault; + } + if ((hostVal as unknown) === UNDOCUMENTED) { + // Host declared the undocumented sentinel — treat as fail-closed (degrade to safe default) + warnings.push(`host axis '${axis}' is undocumented — degraded closed`); + return safeDefault; + } + if (!knownValues.includes(hostVal)) { + // Host declared an unknown/future value — NEVER copy into effective + warnings.push( + `host declared unknown '${axis}' value '${String(hostVal)}' — not trusted (host protocolVersion ${hostPV} vs engine ${enginePV})`, + ); + return safeDefault; + } + // Engine capability cap: if the engine can't drive the host's value, + // use the engine's lesser capability. + // For modelMode: 'active' > 'passive' — if host wants active but engine + // is passive, cap to passive. + if (axis === 'modelMode') { + if (hostVal === 'active' && engineVal === 'passive') return 'passive'; + } + return hostVal; + } + + // Negotiate all scalar axes + const effectiveEmbeddingMode = negotiateScalar('embeddingMode'); + const effectiveCommandSurface = negotiateScalar('commandSurface'); + const effectiveModelMode = negotiateScalar('modelMode'); + const effectiveHookBus = negotiateScalar('hookBus'); + const effectiveStateIO = negotiateScalar('stateIO'); + const effectiveTransport = negotiateScalar('transport'); + const effectiveRuntime = negotiateScalar('runtime'); + + // --------------------------------------------------------------------------- + // Dispatch struct negotiation + // --------------------------------------------------------------------------- + const hostDispatch = (typeof h.dispatch === 'object' && h.dispatch !== null) + ? h.dispatch as Record + : null; + const engineDispatch = engine.axes.dispatch; + + let effectiveNamedDispatch: boolean; + let effectiveNested: boolean; + let effectiveBackground: boolean; + let effectiveBackgroundDispatch: boolean; + let effectiveSubagentToolkit: SubagentToolkit; + let effectiveMaxDepth: number; + + if (hostDispatch === null) { + // Host didn't declare dispatch at all — fail-closed to most-restrictive values + warnings.push(`host did not declare 'dispatch'`); + effectiveNamedDispatch = false; + effectiveNested = false; + effectiveBackground = false; + effectiveBackgroundDispatch = false; + effectiveSubagentToolkit = 'read-only'; + effectiveMaxDepth = 0; + } else { + // N1: observability warnings for 'undocumented' sentinel on dispatch fields + if (hostDispatch.namedDispatch === 'undocumented') { + warnings.push(`dispatch.namedDispatch is undocumented — degraded closed`); + } + if (hostDispatch.nested === 'undocumented') { + warnings.push(`dispatch.nested is undocumented — degraded closed`); + } + if (hostDispatch.background === 'undocumented') { + warnings.push(`dispatch.background is undocumented — degraded closed`); + } + if (hostDispatch.subagentToolkit === 'undocumented') { + warnings.push(`dispatch.subagentToolkit is undocumented — degraded closed (read-only)`); + } + if (hostDispatch.backgroundDispatch === 'undocumented') { + warnings.push(`dispatch.backgroundDispatch is undocumented — degraded closed`); + } + + effectiveNamedDispatch = (hostDispatch.namedDispatch === true) && engineDispatch.namedDispatch; + effectiveNested = (hostDispatch.nested === true) && engineDispatch.nested; + effectiveBackground = (hostDispatch.background === true) && engineDispatch.background; + effectiveBackgroundDispatch = (hostDispatch.backgroundDispatch === true) && engineDispatch.backgroundDispatch; + + // subagentToolkit: fail closed to read-only unless explicitly 'full' + // (an 'undocumented' or 'read-only' value → read-only) + const hostToolkit = hostDispatch.subagentToolkit === 'full' ? 'full' : 'read-only'; + const engineToolkit = engineDispatch.subagentToolkit === 'read-only' ? 'read-only' : 'full'; + effectiveSubagentToolkit = (hostToolkit === 'read-only' || engineToolkit === 'read-only') ? 'read-only' : 'full'; + + // maxDepth: missing/non-number/non-finite → 0 + warning + let hostMaxDepth: number; + if (typeof hostDispatch.maxDepth !== 'number' || !Number.isFinite(hostDispatch.maxDepth)) { + warnings.push(`host dispatch.maxDepth is missing or not a number — treating as 0`); + hostMaxDepth = 0; + } else { + hostMaxDepth = hostDispatch.maxDepth; + } + + // Treat negative as +Infinity for the min, then if result is +Infinity emit -1 + const hDepthNum = hostMaxDepth < 0 ? Infinity : hostMaxDepth; + const eDepthNum = engineDispatch.maxDepth < 0 ? Infinity : engineDispatch.maxDepth; + const minDepth = Math.min(hDepthNum, eDepthNum); + effectiveMaxDepth = minDepth === Infinity ? -1 : minDepth; + + // If namedDispatch is false, cap maxDepth/nested/background/backgroundDispatch to 0/false/false/false (struct consistency) + if (!effectiveNamedDispatch) { + effectiveMaxDepth = 0; + effectiveNested = false; + effectiveBackground = false; + effectiveBackgroundDispatch = false; + } + } + + const effectiveDispatch: DispatchCapability = { + namedDispatch: effectiveNamedDispatch, + nested: effectiveNested, + maxDepth: effectiveMaxDepth, + background: effectiveBackground, + subagentToolkit: effectiveSubagentToolkit, + backgroundDispatch: effectiveBackgroundDispatch, + }; + + // --------------------------------------------------------------------------- + // Assemble effective axes + // --------------------------------------------------------------------------- + const effective: HostIntegrationAxes = { + embeddingMode: effectiveEmbeddingMode, + commandSurface: effectiveCommandSurface, + dispatch: effectiveDispatch, + modelMode: effectiveModelMode, + hookBus: effectiveHookBus, + stateIO: effectiveStateIO, + transport: effectiveTransport, + runtime: effectiveRuntime, + }; + + // --------------------------------------------------------------------------- + // Compute points (fresh objects — mutation-safe) + // --------------------------------------------------------------------------- + const points = {} as Record; + for (const point of INTERFACE_POINTS) { + const hostDeg = degradationFor(point, host); + const effectiveDeg = degradationFor(point, effective); + points[point] = { + hostLevel: hostDeg.level, + effectiveLevel: effectiveDeg.level, + fallback: effectiveDeg.fallback, + }; + } + + // protocolVersion: min of host and engine + const resultProtocolVersion = Math.min(hostPV, enginePV); + + return { + protocolVersion: resultProtocolVersion, + effective, + points, + warnings: [...warnings], // fresh copy + }; +} + +// --------------------------------------------------------------------------- +// shouldFlattenDispatch — ADR-1239 Phase B / #1708 +// --------------------------------------------------------------------------- + +/** + * Returns true when the orchestrator MUST run inline (flatten); false when it + * may be backgrounded. + * + * A host may background only if it can reliably background a nesting-capable + * orchestrator — i.e. both `background` AND `backgroundDispatch` are + * explicitly `true`. Any other value (false, missing, 'undocumented') fails + * closed to inline (the always-safe path). + * + * This graduates the #853 prose rule (originally `RUNTIME === 'codex'`, then + * extended to cursor) to a typed, documentation-sourced decision; codex AND + * cursor are both background-eligible in the registry. See + * docs/reference/host-integration-capability-matrix.md. + * + * Null-safety: if dispatch is null, undefined, or not an object, returns true + * (inline, fail-closed) instead of throwing. + */ +type UnvalidatedDispatch = (Partial & { background?: unknown; backgroundDispatch?: unknown }) | null | undefined; + +function shouldFlattenDispatch(dispatch: UnvalidatedDispatch): boolean { + if (!dispatch || typeof dispatch !== 'object') return true; + const canBackground = dispatch.background === true && dispatch.backgroundDispatch === true; + return !canBackground; +} + +// --------------------------------------------------------------------------- +// Module export (CommonJS — matches existing src/*.cts pattern) +// --------------------------------------------------------------------------- + +export = { + PROTOCOL_VERSION, + UNDOCUMENTED, + HOST_INTEGRATION_AXES, + INTERFACE_POINTS, + PROFILE_BASELINES, + DEFAULT_ENGINE, + degradationFor, + profileOf, + negotiateHostCapabilities, + shouldFlattenDispatch, +}; diff --git a/src/init.cts b/src/init.cts index 00e3da153..6c12e0a18 100644 --- a/src/init.cts +++ b/src/init.cts @@ -2183,7 +2183,7 @@ function buildAgentSkillsBlock( if (entry.kind === 'directive') { return `- Load the \`${entry.name}\` skill via the Skill tool before proceeding (plugin-provided).`; } - return `- @${entry.ref}`; + return `- @${String(entry.ref).replace(/\\/g, '/')}`; }).join('\n'); return `\nRead these user-configured skills:\n${lines}\n`; } diff --git a/src/install-engine.cts b/src/install-engine.cts new file mode 100644 index 000000000..9cf83f660 --- /dev/null +++ b/src/install-engine.cts @@ -0,0 +1,822 @@ +/* eslint-disable @typescript-eslint/no-explicit-any, + @typescript-eslint/no-unsafe-assignment, + @typescript-eslint/no-unsafe-member-access, + @typescript-eslint/no-unsafe-return, + @typescript-eslint/no-unsafe-call, + @typescript-eslint/no-unsafe-argument, + @typescript-eslint/no-require-imports */ +// Mechanical extraction from bin/install.js; keep behavior parity before typing. +'use strict'; + +/** + * Install Engine Module — ADR-1239 Phase B. + * + * Runtime-artifact install/uninstall cluster extracted from bin/install.js. + * bin/install.js imports this module for the layout-driven install/uninstall + * orchestrators and their private helpers. getCommitAttribution STAYS in + * bin/install.js (impure install-time config I/O); it is injected via the + * `resolveAttribution` parameter at each call site. + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import runtimeArtifactConversion = require('./runtime-artifact-conversion.cjs'); +import runtimeArtifactLayout = require('./runtime-artifact-layout.cjs'); +import runtimeArtifactInstallPlan = require('./runtime-artifact-install-plan.cjs'); +import runtimeNamePolicy = require('./runtime-name-policy.cjs'); + +const { processAttribution } = runtimeArtifactConversion; +// resolveRuntimeArtifactLayout: accessed via module ref (not destructured) so +// test stubs that monkeypatch the module's exports are seen at call time. +const { getDirName } = runtimeNamePolicy; +// assertDestWithinConfigHome: must be accessed via module ref at call time for +// test-stub compatibility (monkeypatching the module property works; a local +// const binding from destructure would capture the pre-stub value). +// These are only called from functions that are not stubbed, but we use the +// module ref pattern consistently for correctness. + +// --------------------------------------------------------------------------- +// Types (loose — minimal annotations for strict mode compliance) +// --------------------------------------------------------------------------- + +type ResolveAttribution = (runtime: string) => any; + +// --------------------------------------------------------------------------- +// USER_OWNED_ARTIFACTS +// --------------------------------------------------------------------------- + +/** + * Single source of truth for user-owned artifacts inside gsd-core/. + * + * These files are created/refreshed by user-facing workflows (e.g. + * /gsd-profile-user) and must be preserved across reinstalls. Critically, they + * MUST be excluded from gsd-file-manifest.json — otherwise saveLocalPatches() + * will compare a refreshed file against a stale manifest hash and emit a + * spurious "locally modified GSD file" warning (bug #2771). + * + * Invariant: a file is either distribution (manifest-tracked, diff'd against + * manifest) or user artifact (preserved across installs, never diff'd). Never + * both. Both preserveUserArtifacts call sites and writeManifest must agree on + * this list, which is why it lives here as a single constant. + * + * Paths are relative to the gsd-core/ directory. + */ +const USER_OWNED_ARTIFACTS: string[] = ['USER-PROFILE.md']; + +// --------------------------------------------------------------------------- +// Conversion helpers +// --------------------------------------------------------------------------- + +/** + * Apply per-runtime path-prefix rewrites for OpenCode-family skill bodies. + * Replaces ~/.claude/, $HOME/.claude/, ./.claude/ and OpenCode-variant paths + * with the computed pathPrefix for the install. + */ +function applyOpencodeFamilyPathPrefix(content: string, runtime: string, pathPrefix: string): string { + content = content.replace(/~\/\.claude\//g, pathPrefix); + content = content.replace(/\$HOME\/\.claude\//g, pathPrefix); + content = content.replace(/\.\/\.claude\//g, `./${getDirName(runtime)}/`); + content = content.replace(/~\/\.opencode\//g, pathPrefix); + content = content.replace(/~\/\.kilo\//g, pathPrefix); + return content; +} + +/** + * Convert a Claude command (.md) to an OpenCode skill (SKILL.md). + * The canonical OpenCode-family writer lives in runtime-artifact-conversion.cjs + * (single source of truth — avoids a duplicate writer drifting per + * DEFECT.GENERATIVE-FIX); this thin wrapper delegates to it. + */ +function convertClaudeCommandToOpencodeSkill(content: string, skillName: string): string { + return (runtimeArtifactConversion as any).convertClaudeCommandToOpencodeSkill(content, skillName); +} + +/** + * Convert a Claude command (.md) to a Kilo skill (SKILL.md). + * Thin wrapper over the shared OpenCode-family writer (Kilo shares the schema). + */ +function convertClaudeCommandToKiloSkill(content: string, skillName: string): string { + return (runtimeArtifactConversion as any).convertClaudeCommandToKiloSkill(content, skillName); +} + +// --------------------------------------------------------------------------- +// User-artifact preservation helpers +// --------------------------------------------------------------------------- + +/** + * Save user-generated files from destDir to an in-memory map before a wipe. + * + * @param destDir - Directory that is about to be wiped + * @param fileNames - Relative file names (e.g. ['USER-PROFILE.md']) to preserve + * @returns Map of fileName → file content (only entries that existed) + */ +function preserveUserArtifacts(destDir: string, fileNames: string[]): Map { + const saved = new Map(); + for (const name of fileNames) { + const fullPath = path.join(destDir, name); + if (fs.existsSync(fullPath)) { + try { + saved.set(name, fs.readFileSync(fullPath, 'utf8')); + } catch { /* skip unreadable files */ } + } + } + return saved; +} + +/** + * Restore user-generated files saved by preserveUserArtifacts after a wipe. + * + * @param destDir - Directory that was wiped and recreated + * @param saved - Map returned by preserveUserArtifacts + */ +function restoreUserArtifacts(destDir: string, saved: Map): void { + for (const [name, content] of saved) { + const fullPath = path.join(destDir, name); + try { + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, content, 'utf8'); + } catch { /* skip unwritable paths */ } + } +} + +// --------------------------------------------------------------------------- +// Symlink-escape guard +// --------------------------------------------------------------------------- + +/** + * Returns true if any path component between `root` and `fullPath` is a + * symbolic link (which could redirect writes outside the install root). + */ +function hasExistingSymlinkBetween(root: string, fullPath: string): boolean { + const resolvedRoot = path.resolve(root); + const resolvedFullPath = path.resolve(fullPath); + if (resolvedFullPath !== resolvedRoot && !resolvedFullPath.startsWith(resolvedRoot + path.sep)) { + return true; + } + + let cursor = resolvedRoot; + if (fs.existsSync(cursor) && fs.lstatSync(cursor).isSymbolicLink()) { + return true; + } + + const relative = path.relative(resolvedRoot, resolvedFullPath); + for (const segment of relative.split(path.sep)) { + if (!segment) continue; + cursor = path.join(cursor, segment); + if (!fs.existsSync(cursor)) return false; + if (fs.lstatSync(cursor).isSymbolicLink()) return true; + } + + return false; +} + +// --------------------------------------------------------------------------- +// migrateLegacyDevPreferencesToSkill +// --------------------------------------------------------------------------- + +/** + * Migrate a legacy dev-preferences.md (saved from commands/gsd/) into the + * runtime-aware SKILL.md location used by the writer after #2973. + * + * For runtimes with a nested skills layout (e.g. Hermes: skills/gsd//), + * the target is /skills/gsd/dev-preferences/SKILL.md. + * For runtimes with a flat skills layout (prefix='gsd-'), the target is + * /skills/gsd-dev-preferences/SKILL.md. + * + * Skips silently if no legacy file was preserved, or if a SKILL.md already + * exists at the new location (don't clobber user-customized skill content + * — they may have edited the new file directly). Returns true on actual + * migration so callers can log a one-line confirmation. + * + * @param targetDir - Resolved runtime config directory (e.g. ~/.claude) + * @param saved - Map returned by preserveUserArtifacts + * @param runtime - canonical runtime ID (e.g. 'hermes', 'qwen', 'claude') + * @param scope - install scope + * @returns true if a file was migrated, false otherwise + */ +function migrateLegacyDevPreferencesToSkill(targetDir: string, saved: Map, runtime?: string, scope: string = 'global'): boolean { + if (!saved || !saved.has('dev-preferences.md')) return false; + let skillDir: string; + if (runtime) { + const layout: any = runtimeArtifactLayout.resolveRuntimeArtifactLayout(runtime, targetDir, scope as any); + const skillsKindEntry = layout.kinds.find((k: any) => k.kind === 'skills'); + if (!skillsKindEntry) return false; // runtime has no skills layout at this scope (e.g. cline local) + const stemName = skillsKindEntry.prefix === '' ? 'dev-preferences' : 'gsd-dev-preferences'; + skillDir = path.join(runtimeArtifactInstallPlan.assertDestWithinConfigHome(targetDir, skillsKindEntry.destSubpath), stemName); + } else { + // Legacy fallback for callers that have not yet been updated to pass runtime + skillDir = path.join(runtimeArtifactInstallPlan.assertDestWithinConfigHome(targetDir, 'skills'), 'gsd-dev-preferences'); + } + const skillFile = path.join(skillDir, 'SKILL.md'); + if (fs.existsSync(skillFile)) return false; + // Symlink-escape guard: reject if any path component between targetDir and + // skillDir is a symlink that would redirect writes outside the config root. + if (hasExistingSymlinkBetween(path.resolve(targetDir), skillDir)) { + throw new Error( + `migrateLegacyDevPreferencesToSkill: skillDir "${skillDir}" contains a symlink escaping the install root "${targetDir}" — refusing to write`, + ); + } + try { + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(skillFile, saved.get('dev-preferences.md')!, 'utf8'); + return true; + } catch { + return false; + } +} + +// --------------------------------------------------------------------------- +// _copyStaged +// --------------------------------------------------------------------------- + +/** + * Copy a staged directory's contents into destDir. + * Additive — does not prune (surface.cjs handles pruning). + * + * For skills kind: each child of stagedDir is a `${prefix}${stem}/` dir; copy + * the whole dir into destDir. + * For commands/agents kind: iterate .md files and write them into destDir. + * - commands: write as `${prefix}${stem}.md` unless destSubpath already + * encodes the GSD namespace as its last segment (e.g. `commands/gsd`), in + * which case write as `${stem}.md` (directory IS the namespace). + * - agents: write as-is (files already carry their own `gsd-` prefix). + * For kimi-agents kind: recursively copy generated YAML/prompt files. + */ +function _copyStaged(stagedDir: string, destDir: string, kind: any, configDir: string): void { + // Defense-in-depth: verify destDir is within the install root even if the + // upstream assertDestWithinConfigHome check was somehow bypassed. This guards + // the actual write site against any future call-site drift. + // Fail-closed: every _copyStaged write must declare its install root so the gate + // can confine it. All callers pass configDir; an omitted root is a bug, not a copy. + if (configDir === undefined) { + throw new Error( + '_copyStaged: configDir (install root) is required to confine writes — refusing to write', + ); + } + // Strict-subpath + NUL containment via the canonical gate (shared with the + // layout-driven install plan); throws if destDir escapes the install root. + // destDir here is an absolute path; path.resolve(configDir, absoluteDest) returns it unchanged, so the gate's strict-subpath check still correctly confines it to configDir. + const resolvedDest = runtimeArtifactInstallPlan.assertDestWithinConfigHome(configDir, destDir); + // Symlink-escape guard: reject if any path component between configDir and + // destDir is a symlink that would redirect writes outside configDir. + if (hasExistingSymlinkBetween(path.resolve(configDir), resolvedDest)) { + throw new Error( + `_copyStaged: destDir "${destDir}" contains a symlink escaping the install root "${configDir}" — refusing to write`, + ); + } + // Use the validated absolute path for the actual writes below. + destDir = resolvedDest; + if (!fs.existsSync(stagedDir)) return; + fs.mkdirSync(destDir, { recursive: true }); + + if (kind.kind === 'skills') { + // Each child of stagedDir is a prefixed skill directory: gsd-help/, etc. + for (const entry of fs.readdirSync(stagedDir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const src = path.join(stagedDir, entry.name); + const dest = path.join(destDir, entry.name); + fs.cpSync(src, dest, { recursive: true }); + } + return; + } + + if (kind.kind === 'kimi-agents') { + fs.cpSync(stagedDir, destDir, { recursive: true }); + return; + } + + // commands or agents + const entries = fs.readdirSync(stagedDir, { withFileTypes: true }); + // For commands: apply prefix unless the destSubpath's last segment already + // represents the GSD namespace (e.g. 'commands/gsd' → last segment 'gsd'). + const destLast = path.basename(kind.destSubpath); + const prefixStem = kind.prefix ? kind.prefix.replace(/-$/, '') : ''; + const namespacedByDir = kind.kind === 'commands' && destLast === prefixStem; + + for (const entry of entries) { + if (!entry.isFile()) continue; + if (!entry.name.endsWith('.md')) continue; + const stem = entry.name.slice(0, -3); // strip .md + + let destName: string; + if (kind.kind === 'agents') { + // Agent files already carry the gsd- prefix in the source dir + destName = entry.name; + } else if (namespacedByDir) { + // Directory is the namespace; don't double-prefix the filename + destName = entry.name; + } else { + // Flat commands directory (e.g. command/ for opencode/kilo) + destName = `${kind.prefix}${stem}.md`; + } + + fs.copyFileSync(path.join(stagedDir, entry.name), path.join(destDir, destName)); + } +} + +// --------------------------------------------------------------------------- +// _removeGsdEntries +// --------------------------------------------------------------------------- + +/** + * Remove GSD-prefixed entries from destDir matching kind.prefix. + * For the prefix='' case: the destSubpath IS the namespace — remove the entire + * destDir. (No current runtime uses prefix='' after #947 reversed Hermes; kept + * as a defensive guard for future runtimes.) + */ +function _removeGsdEntries(destDir: string, kind: any): void { + if (!fs.existsSync(destDir)) return; + if (kind.kind === 'kimi-agents') { + for (const fileName of ['gsd.yaml', 'gsd.md']) { + fs.rmSync(path.join(destDir, fileName), { force: true }); + } + const subagentsDir = path.join(destDir, 'subagents'); + if (fs.existsSync(subagentsDir)) { + for (const entry of fs.readdirSync(subagentsDir, { withFileTypes: true })) { + if (!entry.isFile()) continue; + if (!entry.name.startsWith('gsd-')) continue; + if (!entry.name.endsWith('.yaml') && !entry.name.endsWith('.md')) continue; + fs.rmSync(path.join(subagentsDir, entry.name), { force: true }); + } + } + return; + } + if (kind.prefix === '') { + // Whole-namespace removal (Hermes nested case — destSubpath is skills/gsd) + // The directory itself is the GSD namespace, so remove it entirely. + fs.rmSync(destDir, { recursive: true, force: true }); + return; + } + for (const entry of fs.readdirSync(destDir, { withFileTypes: true })) { + if (!entry.name.startsWith(kind.prefix)) continue; + fs.rmSync(path.join(destDir, entry.name), { recursive: true, force: true }); + } +} + +// --------------------------------------------------------------------------- +// _snapshotDir / _restoreDir +// --------------------------------------------------------------------------- + +/** + * Deep-snapshot a directory tree into a Map. + * Returns an empty Map if the directory doesn't exist. + */ +function _snapshotDir(dir: string): Map { + const files = new Map(); + if (!fs.existsSync(dir)) return files; + const walk = (relPath: string, absPath: string) => { + for (const e of fs.readdirSync(absPath, { withFileTypes: true })) { + const childRel = relPath ? path.join(relPath, e.name) : e.name; + const childAbs = path.join(absPath, e.name); + if (e.isDirectory()) walk(childRel, childAbs); + else if (e.isFile()) files.set(childRel, fs.readFileSync(childAbs)); + } + }; + walk('', dir); + return files; +} + +/** + * Restore a directory tree from a Map produced by _snapshotDir. + */ +function _restoreDir(dir: string, snapshot: Map): void { + for (const [relPath, buf] of snapshot) { + const absPath = path.join(dir, relPath); + fs.mkdirSync(path.dirname(absPath), { recursive: true }); + fs.writeFileSync(absPath, buf); + } +} + +// --------------------------------------------------------------------------- +// _removeHermesBareStemDirs +// --------------------------------------------------------------------------- + +/** + * After the layout-driven install loop writes new gsd-/ dirs to + * skills/gsd/, remove any pre-existing bare-stem dirs (skills/gsd//) + * that correspond to the newly installed gsd- entries. + * + * @param nestedGsdDir absolute path to skills/gsd/ category dir + */ +function _removeHermesBareStemDirs(nestedGsdDir: string): void { + if (!fs.existsSync(nestedGsdDir)) return; + const entries = fs.readdirSync(nestedGsdDir, { withFileTypes: true }); + + // Collect the set of stems that were installed as gsd-/ this run. + const installedStems = new Set(); + for (const entry of entries) { + if (entry.isDirectory() && entry.name.startsWith('gsd-')) { + installedStems.add(entry.name.slice('gsd-'.length)); // e.g. 'quick', 'dev-preferences' + } + } + + // Remove any bare / dir for which gsd-/ was just installed. + for (const entry of entries) { + if (entry.isDirectory() && !entry.name.startsWith('gsd-') && installedStems.has(entry.name)) { + fs.rmSync(path.join(nestedGsdDir, entry.name), { recursive: true }); + } + } +} + +// --------------------------------------------------------------------------- +// Legacy migration helpers +// --------------------------------------------------------------------------- + +/** + * Run legacy install migrations that must execute BEFORE the layout-driven + * copy so stale artifacts are cleaned up before new ones are written. + * + * @param runtime + * @param configDir resolved runtime config directory + * @param scope + */ +function _runLegacyInstallMigrations(runtime: string, configDir: string, scope: string = 'global'): void { + const legacyCommandsGsd = path.join(configDir, 'commands', 'gsd'); + + // Claude / Qwen / Hermes: clean up legacy commands/gsd/ and preserve dev-preferences + // for migration. The actual migration call is deferred to after all layout cleanup so + // that for Hermes the flat skills/gsd-*/ removal (below) does not delete the freshly + // created skills/gsd-dev-preferences/ skill dir. + let savedLegacyArtifacts: Map | null = null; + if (runtime === 'claude' || runtime === 'qwen' || runtime === 'hermes') { + if (fs.existsSync(legacyCommandsGsd)) { + savedLegacyArtifacts = preserveUserArtifacts(legacyCommandsGsd, ['dev-preferences.md']); + fs.rmSync(legacyCommandsGsd, { recursive: true }); + } + } + + // Hermes: remove pre-#2841 flat skills/gsd-*/ entries that lived alongside + // the new skills/gsd/ nested layout. + if (runtime === 'hermes') { + const flatSkillsDir = path.join(configDir, 'skills'); + if (fs.existsSync(flatSkillsDir)) { + for (const entry of fs.readdirSync(flatSkillsDir, { withFileTypes: true })) { + if (entry.isDirectory() && entry.name.startsWith('gsd-')) { + fs.rmSync(path.join(flatSkillsDir, entry.name), { recursive: true }); + } + } + } + + // Hermes: bare-stem skills/gsd// cleanup is deferred to AFTER the + // layout-driven install loop in installRuntimeArtifacts, where the exact set + // of staged gsd-/ dirs is known. Removing here (before staging) would + // require readGsdCommandNames() which misses skills like 'dev-preferences' + // that are not in the commands directory. See _removeHermesBareStemDirs(). + } + + // Migrate dev-preferences.md content → runtime-aware SKILL.md location (#2973). + // Done after all layout cleanup so Hermes flat-dir removal does not delete the + // newly created skill dir. No-op if skill file already exists. + if (savedLegacyArtifacts) { + migrateLegacyDevPreferencesToSkill(configDir, savedLegacyArtifacts, runtime, scope); + } +} + +/** + * Run legacy uninstall cleanup that must execute BEFORE the layout-driven + * removal so old-format entries are also cleaned up. + * + * @param runtime + * @param configDir resolved runtime config directory + * @param scope + * @returns saved legacy artifacts for post-removal migration, or null + */ +function _runLegacyUninstallCleanup(runtime: string, configDir: string, scope: string = 'global'): Map | null { + // commands/gsd/ is a legacy location for Qwen, Hermes, and all Claude installs. + // Prior to #1367 fix, Claude-local used commands/gsd/.md (colon-namespaced). + // After #1367, Claude-local uses flat commands/gsd-.md. The inline uninstall + // block (1c) handles removal of flat files; this function handles the legacy + // commands/gsd/ directory for all Claude scopes (global was already included, + // local is now added since that layout is also legacy post-#1367). + // #2973 / Codex review (bd1f06c9): preserve user-owned dev-preferences.md + // before destructive wipe. Migration to skills/gsd-dev-preferences/SKILL.md + // is deferred and returned so the caller can apply it AFTER layout-driven + // removal — this prevents the layout's gsd-* prefix removal from wiping the + // freshly created skill dir (same pattern as _runLegacyInstallMigrations). + let savedLegacyArtifacts: Map | null = null; + // commands/gsd/ is a legacy location for Qwen, Hermes, and Claude global. + // Claude local is intentionally excluded: the inline uninstall block (1c) handles + // commands/gsd/ for claude local, preserving dev-preferences.md by restoring it + // to the same location (#1423). Using migrateLegacyDevPreferencesToSkill here + // (which would redirect to skills/) conflicts with the test contract for local installs. + const isLegacyCommandsGsd = runtime === 'qwen' || runtime === 'hermes' || (runtime === 'claude' && scope === 'global'); + if (isLegacyCommandsGsd) { + const legacyCommandsGsd = path.join(configDir, 'commands', 'gsd'); + if (fs.existsSync(legacyCommandsGsd)) { + savedLegacyArtifacts = preserveUserArtifacts(legacyCommandsGsd, ['dev-preferences.md']); + fs.rmSync(legacyCommandsGsd, { recursive: true }); + } + } + + // Hermes: pre-#2841 flat skills/gsd-*/ entries + if (runtime === 'hermes') { + const flatSkillsDir = path.join(configDir, 'skills'); + if (fs.existsSync(flatSkillsDir)) { + for (const entry of fs.readdirSync(flatSkillsDir, { withFileTypes: true })) { + if (entry.isDirectory() && entry.name.startsWith('gsd-')) { + fs.rmSync(path.join(flatSkillsDir, entry.name), { recursive: true }); + } + } + } + + // Hermes: pre-#947 bare-stem skills/gsd// entries (dirs that do NOT + // start with 'gsd-') — the #3664 layout used prefix='' so GSD-owned skills + // had bare names (e.g. skills/gsd/help/). These are stale on uninstall. + const nestedGsdDirForUninstall = path.join(configDir, 'skills', 'gsd'); + if (fs.existsSync(nestedGsdDirForUninstall)) { + for (const entry of fs.readdirSync(nestedGsdDirForUninstall, { withFileTypes: true })) { + if (entry.isDirectory() && !entry.name.startsWith('gsd-')) { + fs.rmSync(path.join(nestedGsdDirForUninstall, entry.name), { recursive: true }); + } + } + } + } + + // Return saved artifacts so the caller can migrate after layout-driven removal. + return savedLegacyArtifacts; +} + +// --------------------------------------------------------------------------- +// installRuntimeArtifacts +// --------------------------------------------------------------------------- + +/** + * Layout-driven install orchestrator. + * Runs legacy migrations first, then uses resolveRuntimeArtifactLayout to + * determine what artifact kinds to write and where. + * + * @param runtime canonical runtime ID + * @param configDir resolved runtime config directory + * @param scope + * @param resolvedProfile from resolveProfile() / resolveEffectiveProfile() + * @param resolveAttribution injection: (runtime) => attribution string | undefined + */ +function installRuntimeArtifacts( + runtime: string, + configDir: string, + scope: string, + resolvedProfile: any, + resolveAttribution: ResolveAttribution = () => undefined, +): void { + // Legacy cleanup before layout-driven writes + _runLegacyInstallMigrations(runtime, configDir, scope); + + const layout = runtimeArtifactLayout.resolveRuntimeArtifactLayout(runtime, configDir, scope as 'global' | 'local'); + const planResult = runtimeArtifactInstallPlan.createRuntimeArtifactInstallPlan({ + // `Layout` is structurally identical across the layout/install-plan .cjs + // modules but nominally distinct to tsc (untyped .cjs boundary) — bridge it. + layout: layout as any, + resolvedProfile, + homedir: () => os.homedir(), + platform: process.platform, + resolveAttribution, + }); + + const cleanupDirs = planResult.ok ? planResult.plan.cleanupDirs : planResult.cleanupDirs; + try { + if (!planResult.ok) { + throw new Error(planResult.message); + } + + const kindsByName = new Map(layout.kinds.map((kind: any) => [kind.kind as string, kind])); + for (const item of planResult.plan.items) { + const kind: any = kindsByName.get(item.kind); + if (!kind) throw new Error(`Install plan returned unknown artifact kind: ${item.kind}`); + const dest = item.destDir; + // Symlink-escape guard: reject before mkdir if dest (or any component + // between configDir and dest) is a symlink pointing outside configDir. + // mkdirSync follows symlinks, so this must run BEFORE the mkdir call. + if (hasExistingSymlinkBetween(path.resolve(configDir), dest)) { + throw new Error( + `installRuntimeArtifacts: destDir "${dest}" contains a symlink escaping the install root "${configDir}" — refusing to create`, + ); + } + fs.mkdirSync(dest, { recursive: true }); + if (kind.kind === 'skills' && fs.existsSync(dest)) { + // Pre-prune: snapshot user-owned content before _removeGsdEntries wipes it, + // then restore after. This preserves user dirs across a wipe-and-replace + // install (#2973 / #3664). + // + // All runtimes (incl. Hermes after #947) use prefix='gsd-'. + // _removeGsdEntries removes only gsd-* entries; non-gsd-* user dirs are + // untouched. Preserve the explicit user-owned GSD-prefixed skill + // gsd-dev-preferences, which GSD does not reinstall from source but must + // survive the prune (#2973). + const toPreserve = new Map>(); // dirName -> Map + + { + // Preserve explicitly user-owned GSD-prefixed skill dirs. + // gsd-dev-preferences is the sole user-customisable skill in this category. + const USER_OWNED_SKILL_DIRS = ['gsd-dev-preferences']; + for (const dirName of USER_OWNED_SKILL_DIRS) { + const skillDir = path.join(dest, dirName); + if (!fs.existsSync(skillDir)) continue; + const snap = _snapshotDir(skillDir); + if (snap.size > 0) toPreserve.set(dirName, snap); + } + } + + _removeGsdEntries(dest, kind); + _copyStaged(item.sourceDir, dest, kind, configDir); + + // Restore user-owned dirs after the prune+copy + for (const [dirName, snap] of toPreserve) { + _restoreDir(path.join(dest, dirName), snap); + } + } else { + // For non-skills kinds (commands, agents): no user content to preserve; + // just prune stale gsd-* entries and copy new ones. + _removeGsdEntries(dest, kind); + _copyStaged(item.sourceDir, dest, kind, configDir); + } + } + } finally { + for (const dir of cleanupDirs) { + try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best-effort */ } + } + } + + // Hermes: after the install loop has written all gsd-/ dirs to + // skills/gsd/, remove any stale bare-stem dirs (skills/gsd//) that + // correspond to the newly installed gsd- entries. This is the robust + // replacement for the readGsdCommandNames()-based pre-install cleanup that + // missed skills like 'dev-preferences' (#947 adversarial review). + // + // We run this AFTER the install loop so the installed set is authoritative: + // every gsd-/ present now was written this run (or was there before + // with the same prefix). User-owned bare dirs with no gsd- counterpart + // are untouched. + if (runtime === 'hermes') { + const nestedGsdDirForCleanup = path.join(configDir, 'skills', 'gsd'); + _removeHermesBareStemDirs(nestedGsdDirForCleanup); + } +} + +// --------------------------------------------------------------------------- +// installOpencodeFamilySkills +// --------------------------------------------------------------------------- + +/** + * Install the skills layout kind for an OpenCode-family runtime (OpenCode/Kilo). + * + * These runtimes do NOT go through installRuntimeArtifacts (their commands use a + * bespoke flattened-command writer), so this writes ONLY the skills kind + * alongside their existing command/ + agents/ surfaces. Uninstall is already + * layout-driven (uninstallRuntimeArtifacts iterates layout.kinds), so the + * skills/ dir is cleaned up automatically once the layout declares it. + * + * @param runtime - 'opencode' or 'kilo' + * @param targetDir - resolved runtime config directory + * @param rawCommandsDir - staged RAW Claude command dir (caller's _stageSkills output) + * @param pathPrefix - computed config-path prefix for body rewrites + * @param resolveAttribution - injection: (runtime) => attribution string | undefined + * @returns number of gsd-* skill directories written + */ +function installOpencodeFamilySkills( + runtime: string, + targetDir: string, + rawCommandsDir: string, + pathPrefix: string, + resolveAttribution: ResolveAttribution = () => undefined, +): number { + const layout: any = runtimeArtifactLayout.resolveRuntimeArtifactLayout(runtime, targetDir); + const skillsKindEntry = layout.kinds.find((k: any) => k.kind === 'skills'); + if (!skillsKindEntry) return 0; + const rawDir = rawCommandsDir; + if (!rawDir || !fs.existsSync(rawDir)) return 0; + + const converter = runtime === 'kilo' + ? convertClaudeCommandToKiloSkill + : convertClaudeCommandToOpencodeSkill; + + const dest = runtimeArtifactInstallPlan.assertDestWithinConfigHome(targetDir, skillsKindEntry.destSubpath); + // Symlink-escape guard: reject if any path component between targetDir and + // dest is a symlink that would redirect writes outside the config root. + if (hasExistingSymlinkBetween(path.resolve(targetDir), dest)) { + throw new Error( + `installOpencodeFamilySkills: destDir "${dest}" contains a symlink escaping the install root "${targetDir}" — refusing to write`, + ); + } + fs.mkdirSync(dest, { recursive: true }); + + // Preserve user-owned GSD-prefixed skill dirs across the gsd-* prune. + // gsd-dev-preferences is generated by the user (via generate-dev-preferences) + // and lives at /skills/gsd-dev-preferences — _removeGsdEntries + // would otherwise wipe it. Mirrors the preservation in installRuntimeArtifacts + // (#2973). + const USER_OWNED_SKILL_DIRS = ['gsd-dev-preferences']; + const toPreserve = new Map>(); // dirName -> Map + for (const dirName of USER_OWNED_SKILL_DIRS) { + const skillDir = path.join(dest, dirName); + if (!fs.existsSync(skillDir)) continue; + const snap = _snapshotDir(skillDir); + if (snap.size > 0) toPreserve.set(dirName, snap); + } + + _removeGsdEntries(dest, skillsKindEntry); + + let count = 0; + for (const entry of fs.readdirSync(rawDir, { withFileTypes: true })) { + if (!entry.isFile() || !entry.name.endsWith('.md')) continue; + const stem = entry.name.slice(0, -3); + const skillName = `${skillsKindEntry.prefix}${stem}`; + let content = fs.readFileSync(path.join(rawDir, entry.name), 'utf8'); + content = applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix); + content = processAttribution(content, resolveAttribution(runtime)); + content = converter(content, skillName); + const skillDir = path.join(dest, skillName); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content); + count++; + } + + // Restore user-owned dirs after the prune+copy. + for (const [dirName, snap] of toPreserve) { + _restoreDir(path.join(dest, dirName), snap); + } + + return count; +} + +// --------------------------------------------------------------------------- +// uninstallRuntimeArtifacts +// --------------------------------------------------------------------------- + +/** + * Layout-driven uninstall orchestrator. + * Runs legacy cleanup first, then uses resolveRuntimeArtifactLayout to + * determine which GSD-owned entries to remove. + * + * @param runtime canonical runtime ID + * @param configDir resolved runtime config directory + * @param scope + */ +function uninstallRuntimeArtifacts(runtime: string, configDir: string, scope: string): void { + // Legacy cleanup before layout-driven removal (scope-aware to avoid + // removing Claude local commands/gsd/ which is the primary install dir). + // Returns saved user artifacts so we can migrate AFTER layout removal + // (the layout's gsd-* prefix pass would wipe a skill dir created here). + const savedLegacyArtifacts = _runLegacyUninstallCleanup(runtime, configDir, scope); + + const layout: any = runtimeArtifactLayout.resolveRuntimeArtifactLayout(runtime, configDir, scope as any); + const plan: any = runtimeArtifactInstallPlan.createRuntimeArtifactUninstallPlan(layout); + const kindsByName = new Map(layout.kinds.map((kind: any) => [kind.kind as string, kind])); + for (const item of plan.items) { + const kind: any = kindsByName.get(item.kind); + if (!kind) { + throw new Error(`Runtime artifact uninstall plan referenced unknown kind: ${item.kind}`); + } + _removeGsdEntries(item.destDir, kind); + } + + // Hermes: after removing gsd-* skill dirs from skills/gsd/, also remove + // the GSD-managed DESCRIPTION.md and then the category dir itself if it + // contains no user content (#947). _removeGsdEntries removed gsd-* dirs + // but left the category container and DESCRIPTION.md intact. + if (runtime === 'hermes') { + const nestedGsdDir = path.join(configDir, 'skills', 'gsd'); + if (fs.existsSync(nestedGsdDir)) { + // Remove GSD-owned DESCRIPTION.md (written by writeHermesCategoryDescription) + fs.rmSync(path.join(nestedGsdDir, 'DESCRIPTION.md'), { force: true }); + // Remove the category dir if empty (no user content remaining) + const remaining = fs.readdirSync(nestedGsdDir, { withFileTypes: true }); + if (remaining.length === 0) { + fs.rmSync(nestedGsdDir, { recursive: true, force: true }); + } + } + } + + // #2973 / Codex review (bd1f06c9): migrate dev-preferences.md to the + // runtime-aware SKILL.md location after all layout-driven removal is + // complete. Do NOT restore to commands/gsd/ — the user is uninstalling. + if (savedLegacyArtifacts) { + migrateLegacyDevPreferencesToSkill(configDir, savedLegacyArtifacts, runtime, scope); + } +} + +// --------------------------------------------------------------------------- +// Exports +// --------------------------------------------------------------------------- + +export = { + installRuntimeArtifacts, + uninstallRuntimeArtifacts, + installOpencodeFamilySkills, + _copyStaged, + hasExistingSymlinkBetween, + preserveUserArtifacts, + restoreUserArtifacts, + migrateLegacyDevPreferencesToSkill, + applyOpencodeFamilyPathPrefix, + convertClaudeCommandToOpencodeSkill, + convertClaudeCommandToKiloSkill, + USER_OWNED_ARTIFACTS, + _runLegacyInstallMigrations, + _runLegacyUninstallCleanup, + _removeGsdEntries, + _snapshotDir, + _restoreDir, + _removeHermesBareStemDirs, +}; diff --git a/src/install-profiles.cts b/src/install-profiles.cts index 4d1a929ce..90674c367 100644 --- a/src/install-profiles.cts +++ b/src/install-profiles.cts @@ -11,6 +11,19 @@ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { platformWriteSync } from './shell-command-projection.cjs'; +// eslint-disable-next-line @typescript-eslint/no-require-imports +import conversionModule = require('./runtime-artifact-conversion.cjs'); +const { + applyAgentPathRewrites: _applyAgentPathRewrites, + processAttribution: _processAttribution, + normalizeAgentBodyForRuntime: _normalizeAgentBodyForRuntime, + readGsdCommandNames: _readGsdCommandNames, +} = conversionModule as { + applyAgentPathRewrites: (content: string, runtime: string, pathPrefix: string) => string; + processAttribution: (content: string, attribution: string | null | undefined) => string; + normalizeAgentBodyForRuntime: (content: string, runtime: string, cmdNames: string[]) => string; + readGsdCommandNames: () => string[]; +}; // --------------------------------------------------------------------------- // Profile definitions @@ -530,6 +543,18 @@ function stageSkillsForRuntimeAsSkills( return stageDir; } +/** + * Cross-cutting context for descriptor-driven agent staging (ADR-1235 §1). + * When present, stageAgentsForRuntimeWithConverter applies the full inline-loop + * sequence per agent: pathRewrites → attribution → converter → normalize. + * The field names mirror the inline loop's available identifiers. + */ +interface AgentCtx { + runtime: string; + pathPrefix: string; + attribution: string | null | undefined; +} + /** * Stage a converted copy of the agents directory for a given runtime. * @@ -546,24 +571,39 @@ function stageSkillsForRuntimeAsSkills( * For tiered profiles, only agents whose full stem is in `resolvedProfile.agents` * are staged (mirrors `stageAgentsForProfile` behaviour). * + * ADR-1235 §1: when `agentCtx` is provided, the per-file order matches the inline + * agent loop in bin/install.js exactly: + * 1. applyAgentPathRewrites (4 base ~/.claude/ regexes; skipped for copilot/antigravity) + * 2. processAttribution (Co-Authored-By policy) + * 3. converter (runtime-specific frontmatter/body transform) + * 4. normalizeAgentBodyForRuntime (colon→hyphen refs; no-op for trivial group) + * When `agentCtx` is absent, only the converter is applied (backward-compat for + * the feat-1173 synthetic-descriptor tests and the copilot/antigravity paths + * that handle cross-cutting inside their converters). + * * @param srcAgentsDir source agents directory (e.g. agents/) * @param resolvedProfile profile filter from resolveProfile() * @param converter (content: string, isGlobal?: boolean) → string per-file * converter; scope-aware converters (copilot/antigravity) * read isGlobal, single-arg converters ignore it (#1173) * @param isGlobal install scope passed through to the converter + * @param agentCtx optional cross-cutting context (ADR-1235 §1); when absent, + * only the converter is applied (backward compat) */ function stageAgentsForRuntimeWithConverter( srcAgentsDir: string, resolvedProfile: ResolvedProfile, converter: (content: string, isGlobal?: boolean) => string, isGlobal = false, + agentCtx?: AgentCtx, ): string { if (!fs.existsSync(srcAgentsDir)) return srcAgentsDir; const stageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-profile-runtime-agents-')); try { const entries = fs.readdirSync(srcAgentsDir, { withFileTypes: true }); + // Resolve cmdNames once per staging call (not per file) for performance. + const cmdNames = agentCtx ? _readGsdCommandNames() : []; for (const entry of entries) { if (!entry.isFile()) continue; if (!entry.name.endsWith('.md')) continue; @@ -574,9 +614,22 @@ function stageAgentsForRuntimeWithConverter( continue; } } - const content = fs.readFileSync(path.join(srcAgentsDir, entry.name), 'utf8'); - const converted = converter(content, isGlobal); - fs.writeFileSync(path.join(stageDir, entry.name), converted, 'utf8'); + let content = fs.readFileSync(path.join(srcAgentsDir, entry.name), 'utf8'); + if (agentCtx) { + // ADR-1235 §1: pre-converter cross-cutting (matches inline loop order exactly) + // Step 1: path rewrites (4 base ~/.claude/ regexes; skipped for copilot/antigravity) + content = _applyAgentPathRewrites(content, agentCtx.runtime, agentCtx.pathPrefix); + // Step 2: attribution + content = _processAttribution(content, agentCtx.attribution); + // Step 3: converter (runtime-specific frontmatter/body transform) + content = converter(content, isGlobal); + // Step 4: normalize colon→hyphen refs (no-op for trivial group) + content = _normalizeAgentBodyForRuntime(content, agentCtx.runtime, cmdNames); + } else { + // Backward-compat: only apply the converter (no cross-cutting) + content = converter(content, isGlobal); + } + fs.writeFileSync(path.join(stageDir, entry.name), content, 'utf8'); } } catch (err) { try { fs.rmSync(stageDir, { recursive: true, force: true }); } catch { /* best-effort */ } diff --git a/src/installer-migrations.cts b/src/installer-migrations.cts index 95105cd5b..bbc82f355 100644 --- a/src/installer-migrations.cts +++ b/src/installer-migrations.cts @@ -16,7 +16,7 @@ import { type MigrationRecord, type MigrationAction, } from './installer-migration-authoring.cjs'; -import { platformWriteSync } from './shell-command-projection.cjs'; +import { platformWriteSync, retryRenameSync } from './shell-command-projection.cjs'; import { realClock, type Clock } from './clock.cjs'; const MANIFEST_NAME = 'gsd-file-manifest.json'; @@ -105,7 +105,7 @@ function atomicWriteInstallState(configDir: string, content: string): void { const tmpPath = `${filePath}.tmp-${process.pid}-${Date.now()}`; try { fs.writeFileSync(tmpPath, content, 'utf8'); - fs.renameSync(tmpPath, filePath); + retryRenameSync(tmpPath, filePath); } catch (error) { try { fs.rmSync(tmpPath, { force: true }); } catch { /* best-effort */ } throw error; diff --git a/src/milestone.cts b/src/milestone.cts index f15c9baad..2b8e83f9e 100644 --- a/src/milestone.cts +++ b/src/milestone.cts @@ -14,7 +14,7 @@ import planningWorkspace = require('./planning-workspace.cjs'); import frontmatterMod = require('./frontmatter.cjs'); // eslint-disable-next-line @typescript-eslint/no-require-imports -- state.cjs is an export= CommonJS module import stateMod = require('./state.cjs'); -import { platformWriteSync, platformEnsureDir, execGit } from './shell-command-projection.cjs'; +import { platformWriteSync, platformEnsureDir, execGit, retryRenameSync } from './shell-command-projection.cjs'; import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs'; // eslint-disable-next-line @typescript-eslint/no-require-imports import ioMod = require('./io.cjs'); @@ -184,6 +184,13 @@ function cmdMilestoneComplete(cwd: string, version: string, options: MilestoneCo })(); while ((pm = phasePattern.exec(scopedContent)) !== null) { const phaseNum = pm[1]; + // Phase 0 (pre-milestone) and Phase 999 (backlog) are sentinels, not + // real phases — they legitimately have no directory and must not block + // milestone completion. Mirrors the engine-wide sentinel convention + // (phase-id getMilestoneFromPhaseId, roadmap-command-router SENTINELS, + // the #1445 /^999/ progress filters). (#1580) + const major = parseInt(phaseNum, 10); + if (major === 0 || major === 999) continue; const normalized = normalizePhaseName(phaseNum); // A phase has disk_status: 'no_directory' when no phase directory // with a matching token exists on disk. Use the same phaseTokenMatches @@ -276,7 +283,7 @@ function cmdMilestoneComplete(cwd: string, version: string, options: MilestoneCo // Archive audit file if exists const auditFile = path.join(cwd, '.planning', `${version}-MILESTONE-AUDIT.md`); if (fs.existsSync(auditFile)) { - fs.renameSync(auditFile, path.join(archiveDir, `${version}-MILESTONE-AUDIT.md`)); + retryRenameSync(auditFile, path.join(archiveDir, `${version}-MILESTONE-AUDIT.md`)); } // Create/append MILESTONES.md entry @@ -357,7 +364,7 @@ function cmdMilestoneComplete(cwd: string, version: string, options: MilestoneCo let archivedCount = 0; for (const dir of phaseDirNames) { if (!isDirInMilestone(dir)) continue; - fs.renameSync(path.join(phasesDir, dir), path.join(phaseArchiveDir, dir)); + retryRenameSync(path.join(phasesDir, dir), path.join(phaseArchiveDir, dir)); archivedCount++; } phasesArchived = archivedCount > 0; diff --git a/src/phase.cts b/src/phase.cts index 4e0a3ae4a..3e95e08f6 100644 --- a/src/phase.cts +++ b/src/phase.cts @@ -49,7 +49,7 @@ import planningWorkspace = require('./planning-workspace.cjs'); import frontmatterMod = require('./frontmatter.cjs'); // eslint-disable-next-line @typescript-eslint/no-require-imports -- state.cjs is an export= CommonJS module import stateMod = require('./state.cjs'); -import { platformWriteSync, platformReadSync, platformEnsureDir } from './shell-command-projection.cjs'; +import { platformWriteSync, platformReadSync, platformEnsureDir, retryRenameSync } from './shell-command-projection.cjs'; import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs'; import { deriveProgressFromRoadmap, clampPercent } from './phase-lifecycle.cjs'; import { realClock } from './clock.cjs'; @@ -1068,12 +1068,12 @@ function renameDecimalPhases( const oldPhaseId = `${baseInt}.${item.oldDecimal}`; const newPhaseId = `${baseInt}.${newDecimal}`; const newDirName = `${item.prefix}.${newDecimal}-${item.slug}`; - fs.renameSync(path.join(phasesDir, item.dir), path.join(phasesDir, newDirName)); + retryRenameSync(path.join(phasesDir, item.dir), path.join(phasesDir, newDirName)); renamedDirs.push({ from: item.dir, to: newDirName }); for (const f of fs.readdirSync(path.join(phasesDir, newDirName))) { if (f.includes(oldPhaseId)) { const newFileName = f.replace(oldPhaseId, newPhaseId); - fs.renameSync( + retryRenameSync( path.join(phasesDir, newDirName, f), path.join(phasesDir, newDirName, newFileName), ); @@ -1120,12 +1120,12 @@ function renameIntegerPhases( const oldPrefix = `${oldPadded}${letterSuffix}${decimalSuffix}`; const newPrefix = `${newPadded}${letterSuffix}${decimalSuffix}`; const newDirName = `${newPrefix}-${item.slug}`; - fs.renameSync(path.join(phasesDir, item.dir), path.join(phasesDir, newDirName)); + retryRenameSync(path.join(phasesDir, item.dir), path.join(phasesDir, newDirName)); renamedDirs.push({ from: item.dir, to: newDirName }); for (const f of fs.readdirSync(path.join(phasesDir, newDirName))) { if (f.startsWith(oldPrefix)) { const newFileName = newPrefix + f.slice(oldPrefix.length); - fs.renameSync( + retryRenameSync( path.join(phasesDir, newDirName, f), path.join(phasesDir, newDirName, newFileName), ); diff --git a/src/planning-workspace.cts b/src/planning-workspace.cts index b86147eb4..d3017d9b3 100644 --- a/src/planning-workspace.cts +++ b/src/planning-workspace.cts @@ -15,7 +15,7 @@ import fs from 'node:fs'; import path from 'node:path'; -import { platformEnsureDir } from './shell-command-projection.cjs'; +import { platformEnsureDir, retryRenameSync } from './shell-command-projection.cjs'; import { realClock } from './clock.cjs'; import type { Clock } from './clock.cjs'; // eslint-disable-next-line @typescript-eslint/no-require-imports @@ -276,7 +276,7 @@ function withPlanningLock(cwd: string, fn: () => T, clock?: Clock): T { // we must NOT fall through to a delete — back off and retry the create. const stolen = lockPath + '.stale-' + process.pid + '-' + clock.now() + '-' + (_planningStealSeq++); let renamed = false; - try { fs.renameSync(lockPath, stolen); renamed = true; } catch { /* another racer won */ } + try { retryRenameSync(lockPath, stolen); renamed = true; } catch { /* another racer won */ } if (renamed) { try { fs.rmSync(stolen, { force: true }); } catch { /* best-effort */ } continue; // dead/garbage/expired holder freed — retry immediately to grab it. diff --git a/src/roadmap-upgrade.cts b/src/roadmap-upgrade.cts index 76632d293..3487a0334 100644 --- a/src/roadmap-upgrade.cts +++ b/src/roadmap-upgrade.cts @@ -10,6 +10,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { execSync } from 'node:child_process'; +import { retryRenameSync } from './shell-command-projection.cjs'; // eslint-disable-next-line @typescript-eslint/no-require-imports import planningWorkspace = require('./planning-workspace.cjs'); // eslint-disable-next-line @typescript-eslint/no-require-imports @@ -523,7 +524,7 @@ function applyMigration(cwd: string, plan: MigrationPlan, options: { dryRun?: bo const oldPath = path.join(phasesDir, phaseEntry.oldDir); const newPath = path.join(phasesDir, phaseEntry.newDir); if (fs.existsSync(oldPath)) { - fs.renameSync(oldPath, newPath); + retryRenameSync(oldPath, newPath); performedRenames.push({ oldPath, newPath }); renamedDirs.push(`${phaseEntry.oldDir} → ${phaseEntry.newDir}`); } @@ -597,7 +598,7 @@ function applyMigration(cwd: string, plan: MigrationPlan, options: { dryRun?: bo for (let i = performedRenames.length - 1; i >= 0; i--) { const { oldPath, newPath } = performedRenames[i]; try { - if (fs.existsSync(newPath)) fs.renameSync(newPath, oldPath); + if (fs.existsSync(newPath)) retryRenameSync(newPath, oldPath); } catch { /* best-effort */ } } for (const [filePath, backup] of fileBackups) { diff --git a/src/roadmap.cts b/src/roadmap.cts index 1c442c502..510edf647 100644 --- a/src/roadmap.cts +++ b/src/roadmap.cts @@ -318,6 +318,16 @@ function cmdRoadmapAnalyze(cwd: string, raw: boolean): void { }> = []; let match: RegExpExecArray | null; + // Phase 0 (pre-milestone) and Phase 999 (backlog) are sentinels, not real + // phases. They legitimately have no directory and must never be surfaced as + // current/next phase or counted in phase_count. Mirrors the engine-wide + // sentinel convention (phase-id getMilestoneFromPhaseId, roadmap-command-router + // SENTINELS, the #1445 /^999/ progress filters). (#1580) + const isSentinelPhase = (num: string): boolean => { + const major = parseInt(num, 10); + return major === 0 || major === 999; + }; + // Build phase directory lookup once (O(1) readdir instead of O(N) per phase) const _phaseDirNames = (() => { try { @@ -329,6 +339,7 @@ function cmdRoadmapAnalyze(cwd: string, raw: boolean): void { while ((match = phasePattern.exec(content)) !== null) { const phaseNum = match[1]; + if (isSentinelPhase(phaseNum)) continue; const phaseName = match[2].replace(/\(INSERTED\)/i, '').trim(); // Extract goal from the section @@ -437,7 +448,7 @@ function cmdRoadmapAnalyze(cwd: string, raw: boolean): void { checklistPhases.add(checklistMatch[1]); } const detailPhases = new Set(phases.map(p => p.number)); - const missingDetails = [...checklistPhases].filter(p => !detailPhases.has(p)); + const missingDetails = [...checklistPhases].filter(p => !detailPhases.has(p) && !isSentinelPhase(p)); const result = { milestones, diff --git a/src/runtime-artifact-conversion.cts b/src/runtime-artifact-conversion.cts index 77cb11e02..56bf688c6 100644 --- a/src/runtime-artifact-conversion.cts +++ b/src/runtime-artifact-conversion.cts @@ -23,6 +23,7 @@ import commandRoster = require('./command-roster.cjs'); const { readGsdCommandNames, transformContentToHyphen } = commandRoster; import runtimeNamePolicy = require('./runtime-name-policy.cjs'); const { getDirName } = runtimeNamePolicy; +import capabilityRegistry = require('./capability-registry.cjs'); // #1383: resolve GSD's version WITHOUT a top-level // `require('../../../package.json')`. That require ran at module load on every @@ -2199,16 +2200,15 @@ function computePathPrefix({ isGlobal, isOpencode, isWindowsHost: _isWindowsHost /** * Canonical list of every non-Claude runtime that gsd-core emits artifacts for. - * Exported so test files can import this single source of truth rather than - * maintaining divergent hand-rolled arrays (#1521). - * - * Keep in sync with the runtime flags in bin/install.js and getDirName(). + * DERIVED from the capability registry (ADR-1239 Phase B, #1679) — the registry's + * `runtimes` map is the single source of truth for runtime identity, so the + * non-Claude set is its key set minus 'claude'. This replaces a hand-maintained + * literal that had to be kept in sync with bin/install.js and getDirName(), and + * can no longer drift from the registry. Exported so tests import one source (#1521). */ -const NON_CLAUDE_RUNTIMES: string[] = [ - 'codex', 'opencode', 'kilo', 'gemini', 'copilot', 'antigravity', - 'cursor', 'windsurf', 'augment', 'trae', 'qwen', 'hermes', 'kimi', - 'codebuddy', 'cline', -]; +const NON_CLAUDE_RUNTIMES: string[] = Object.keys(capabilityRegistry.runtimes) + .filter((id) => id !== 'claude') + .sort(); /** * #1521: Every non-Claude runtime resolves its own runtime identity from a @@ -2551,6 +2551,60 @@ function rewriteStagedCommandBodies(stagedDir, opts) { return applyRuntimeContentRewritesForCommandsInPlace(stagedDir, runtime, pathPrefix, isGlobal, attribution); } +/** + * Runtimes that use the hyphen-namespace form `/gsd-` in agent bodies. + * claude/qwen/hermes use hyphen-name:`...` frontmatter; cursor/windsurf/etc + * self-convert. Mirrors the `HYPHEN_NAME_AGENT_RUNTIMES` set in bin/install.js. + * + * @private — export normalizeAgentBodyForRuntime for callers. + */ +const HYPHEN_NAME_AGENT_RUNTIMES: ReadonlySet = new Set(['claude', 'qwen', 'hermes']); + +/** + * Normalize `/gsd:` colon refs in the agent body to `/gsd-` for + * hyphen-`name:` runtimes (claude / qwen / hermes). No-op for all other + * runtimes. Mirrors the per-file call in bin/install.js line 9400. + * + * @param content raw agent file content (post-converter) + * @param runtime canonical runtime ID + * @param cmdNames gsd command names from readGsdCommandNames() + */ +function normalizeAgentBodyForRuntime(content: string, runtime: string, cmdNames: string[]): string { + if (!HYPHEN_NAME_AGENT_RUNTIMES.has(runtime)) return content; + return transformContentToHyphen(content, cmdNames); +} + +/** + * Apply the 4 base `~/.claude/` path-prefix rewrites to a single agent content + * string. Mirrors the inline agent loop in bin/install.js lines 9330-9340: + * ~/\.claude/ → pathPrefix + * $HOME/\.claude/ → pathPrefix + * ~/\.claude\b → normalizedPathPrefix + * $HOME/\.claude\b → normalizedPathPrefix + * + * Skipped for copilot and antigravity (which do NOT do path rewrites in the + * inline loop). NO stamp (_stampNonClaudeRuntimeDefaults) — agents are NOT + * stamped in the inline loop. + * + * ADR-1235 §1: pre-converter cross-cutting for descriptor-driven agent pipeline. + * Exported as `applyAgentPathRewrites` for testing and for injection into + * stageAgentsForRuntimeWithConverter via agentCtx. + * + * @param content raw agent file content + * @param runtime canonical runtime ID + * @param pathPrefix trailing-slash path prefix (e.g. '$HOME/.cursor/') + * @returns content with path-prefix rewrites applied (or unchanged for copilot/antigravity) + */ +function applyAgentPathRewrites(content: string, runtime: string, pathPrefix: string): string { + if (runtime === 'copilot' || runtime === 'antigravity') return content; + const normalizedPathPrefix = pathPrefix.replace(/\/$/, ''); + content = content.replace(/~\/\.claude\//g, pathPrefix); + content = content.replace(/\$HOME\/\.claude\//g, pathPrefix); + content = content.replace(/~\/\.claude\b/g, normalizedPathPrefix); + content = content.replace(/\$HOME\/\.claude\b/g, normalizedPathPrefix); + return content; +} + // ── End rewrite engine ──────────────────────────────────────────────────────── /** @@ -2644,6 +2698,9 @@ export = { // High-level wrappers (derive pathPrefix + attribution from opts): rewriteStagedSkillBodies, rewriteStagedCommandBodies, + // ADR-1235 §1: descriptor-driven agent cross-cutting + applyAgentPathRewrites, + normalizeAgentBodyForRuntime, _computePathPrefix: computePathPrefix, _applyRuntimeRewrites, _stampNonClaudeRuntimeDefaults, diff --git a/src/runtime-artifact-install-plan.cts b/src/runtime-artifact-install-plan.cts index d6b2e6903..aea0a0218 100644 --- a/src/runtime-artifact-install-plan.cts +++ b/src/runtime-artifact-install-plan.cts @@ -21,11 +21,17 @@ interface ResolvedProfile { agents?: Set; } +interface AgentCtx { + runtime: string; + pathPrefix: string; + attribution: string | null | undefined; +} + interface ArtifactKind { kind: ArtifactKindName; destSubpath: string; prefix?: string; - stage: (resolvedProfile: ResolvedProfile) => string; + stage: (resolvedProfile: ResolvedProfile, agentCtx?: AgentCtx) => string; } interface Layout { @@ -49,9 +55,18 @@ interface Dependencies { rewriteStagedCommandBodies?: (stagedDir: string, opts: RewriteOpts) => string | void; } +interface ComputePathPrefixOpts { + isGlobal: boolean; + isOpencode: boolean; + isWindowsHost: boolean; + resolvedTarget: string; + homeDir: string; +} + interface RuntimeArtifactConversionExports { rewriteStagedSkillBodies: (stagedDir: string, opts: RewriteOpts) => string | void; rewriteStagedCommandBodies: (stagedDir: string, opts: RewriteOpts) => string | void; + _computePathPrefix: (opts: ComputePathPrefixOpts) => string; } interface PlanItem { @@ -87,6 +102,35 @@ interface CreateRuntimeArtifactInstallPlanArgs { deps?: Dependencies; } +/** + * Asserts that `destSubpath` resolves to a path inside `configDir`. + * + * Rejects any path that escapes the configDir root (e.g. "../../etc") and any + * path containing a NUL byte. This is a security gate for Phase B of + * ADR-1239: third-party descriptors must never be able to write outside the + * designated config home directory. + * + * @param configDir - The root config directory (e.g. ~/.claude). + * @param destSubpath - The relative path declared by the runtime descriptor. + * @returns The resolved absolute path under configDir. + * @throws {Error} if destSubpath escapes configDir or contains a NUL byte. + */ +function assertDestWithinConfigHome(configDir: string, destSubpath: string): string { + if (destSubpath.includes('\0')) { + throw new Error( + `destSubpath "${destSubpath}" contains a NUL byte and is not valid`, + ); + } + const root = path.resolve(configDir); + const resolved = path.resolve(configDir, destSubpath); + if (resolved === root || !resolved.startsWith(root + path.sep)) { + throw new Error( + `destSubpath "${destSubpath}" must be a strict subpath of configHome "${configDir}" — not configHome itself or outside it (escapes configHome)`, + ); + } + return resolved; +} + function errorMessage(err: unknown): string { if (err instanceof Error) return err.message; return String(err); @@ -122,10 +166,34 @@ function createRuntimeArtifactInstallPlan(args: CreateRuntimeArtifactInstallPlan resolveAttribution, }; + // ADR-1235 §1: build agentCtx once per plan so agents kind entries can apply + // the CORRECT pre-converter cross-cutting (path rewrites → attribution → converter + // → normalize). This mirrors the exact per-file order in the inline agent loop + // in bin/install.js (lines 9330-9415). agentCtx is passed as the second arg + // to kind.stage() for agents kind entries with a converter (convertedAgentsKind). + // NO _stampNonClaudeRuntimeDefaults — agents are NOT stamped in the inline loop. + const os = _require('node:os') as typeof import('node:os'); + const homedirFn: () => string = homedir ?? (() => os.homedir()); + const resolvedTarget = path.resolve(layout.configDir).replace(/\\/g, '/'); + const homeDir = homedirFn().replace(/\\/g, '/'); + const isGlobal = scope === 'global'; + const isOpencode = layout.runtime === 'opencode'; + const isWindowsHost = (platform ?? process.platform) === 'win32'; + const pathPrefix = conversionExports._computePathPrefix({ isGlobal, isOpencode, isWindowsHost, resolvedTarget, homeDir }); + const attribution = resolveAttribution ? resolveAttribution(layout.runtime) : undefined; + const agentCtx: AgentCtx = { runtime: layout.runtime, pathPrefix, attribution }; + for (const kind of layout.kinds) { let stagedDir: string; try { - stagedDir = kind.stage(resolvedProfile); + if (kind.kind === 'agents') { + // ADR-1235 §1: pass agentCtx so stageAgentsForRuntimeWithConverter applies + // the full inline-loop order: pathRewrites → attribution → converter → normalize. + // The cross-cutting is now PRE-converter (inside staging), not POST. + stagedDir = kind.stage(resolvedProfile, agentCtx); + } else { + stagedDir = kind.stage(resolvedProfile); + } } catch (err) { return { ok: false, kind: 'stage_failed', message: errorMessage(err), cleanupDirs, failedKind: kind.kind }; } @@ -139,6 +207,8 @@ function createRuntimeArtifactInstallPlan(args: CreateRuntimeArtifactInstallPlan const rewrittenDir = rewriteStagedSkillBodies(stagedDir, rewriteOpts); sourceDir = addCleanupDir(cleanupDirs, stagedDir, rewrittenDir); } + // agents kind: cross-cutting already applied INSIDE kind.stage() via agentCtx. + // No POST-step needed. sourceDir stays as stagedDir. } catch (err) { return { ok: false, kind: 'rewrite_failed', message: errorMessage(err), cleanupDirs, failedKind: kind.kind }; } @@ -146,7 +216,7 @@ function createRuntimeArtifactInstallPlan(args: CreateRuntimeArtifactInstallPlan items.push({ kind: kind.kind, sourceDir, - destDir: path.join(layout.configDir, kind.destSubpath), + destDir: assertDestWithinConfigHome(layout.configDir, kind.destSubpath), }); } @@ -157,9 +227,9 @@ function createRuntimeArtifactUninstallPlan(layout: Layout): UninstallPlan { return { items: layout.kinds.map((kind) => ({ kind: kind.kind, - destDir: path.join(layout.configDir, kind.destSubpath), + destDir: assertDestWithinConfigHome(layout.configDir, kind.destSubpath), })), }; } -export = { createRuntimeArtifactInstallPlan, createRuntimeArtifactUninstallPlan }; +export = { assertDestWithinConfigHome, createRuntimeArtifactInstallPlan, createRuntimeArtifactUninstallPlan }; diff --git a/src/runtime-artifact-layout.cts b/src/runtime-artifact-layout.cts index aceb479c9..073d670b8 100644 --- a/src/runtime-artifact-layout.cts +++ b/src/runtime-artifact-layout.cts @@ -54,11 +54,25 @@ interface ResolvedProfile { agents: Set; } +/** + * Cross-cutting context for descriptor-driven agent staging (ADR-1235 §1). + * Passed as the optional second arg to ArtifactKind.stage() for agents kind + * entries so that stageAgentsForRuntimeWithConverter can apply the exact + * inline-loop transform order: pathRewrites → attribution → converter → normalize. + */ +interface AgentCtx { + runtime: string; + pathPrefix: string; + attribution: string | null | undefined; +} + interface ArtifactKind { kind: KimiArtifactKindName; destSubpath: string; prefix: string; - stage: (resolvedProfile: ResolvedProfile) => string; + /** For agents kind with a converter, accepts an optional AgentCtx as the second + * arg so cross-cutting can be applied pre-converter (ADR-1235 §1). */ + stage: (resolvedProfile: ResolvedProfile, agentCtx?: AgentCtx) => string; } interface Layout { @@ -207,17 +221,21 @@ function convertedAgentsKind( kind: 'agents', destSubpath, prefix, - stage: (resolved) => { + stage: (resolved, agentCtx) => { // isGlobal is threaded so scope-aware agent converters (copilot, antigravity) // choose global-home vs workspace-relative paths; converters that only take // (content) ignore the extra positional arg. Mirrors skillsKind's scope // threading (#1173). const converter = conversionExports[converterName] as (content: string, isGlobal?: boolean) => string; + // ADR-1235 §1: when agentCtx is provided (by createRuntimeArtifactInstallPlan + // for descriptor-driven runtimes), thread it through so stageAgentsForRuntimeWithConverter + // can apply the full pre-converter + post-converter sequence in the correct order. return stageAgentsForRuntimeWithConverter( findAgentsSourceRoot(configDir), resolved, converter, scope === 'global', + agentCtx, ); }, }; diff --git a/src/runtime-hooks-surface.cts b/src/runtime-hooks-surface.cts index 3497f18ed..b98a5eacf 100644 --- a/src/runtime-hooks-surface.cts +++ b/src/runtime-hooks-surface.cts @@ -110,7 +110,7 @@ function atomicWriteFileSync(target: string, data: string, options: fs.WriteFile __atomicWrittenTmps.add(tmp); try { fs.writeFileSync(tmp, data, options); - fs.renameSync(tmp, target); + shellCmdProjection.retryRenameSync(tmp, target); // Successful rename: the tmp path no longer exists, but leave it in the // Set so _cleanTmpFiles can recognise it as installer-owned if it somehow // lingers (e.g. a rename succeeded but left a stale entry on some FS). diff --git a/src/runtime-name-policy.cts b/src/runtime-name-policy.cts index 0204106d0..6b20fe053 100644 --- a/src/runtime-name-policy.cts +++ b/src/runtime-name-policy.cts @@ -142,20 +142,12 @@ export function getProjectInstructionFile(runtime: unknown): string { * `bin/install.js` re-exports this same function for back-compat. */ export function getDirName(runtime: string): string { - if (runtime === 'copilot') return '.github'; - if (runtime === 'opencode') return '.opencode'; - if (runtime === 'gemini') return '.gemini'; - if (runtime === 'kilo') return '.kilo'; - if (runtime === 'codex') return '.codex'; - if (runtime === 'antigravity') return '.agents'; - if (runtime === 'cursor') return '.cursor'; - if (runtime === 'windsurf') return '.windsurf'; - if (runtime === 'augment') return '.augment'; - if (runtime === 'trae') return '.trae'; - if (runtime === 'qwen') return '.qwen'; - if (runtime === 'hermes') return '.hermes'; - if (runtime === 'kimi') return '.kimi-code'; - if (runtime === 'codebuddy') return '.codebuddy'; - if (runtime === 'cline') return '.cline'; + if (!runtime) return '.claude'; + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { runtimes } = require('./capability-registry.cjs') as { + runtimes: Record; + }; + const dir = runtimes[runtime]?.runtime?.localConfigDir; + if (typeof dir === 'string' && dir.length > 0) return dir; return '.claude'; } diff --git a/src/shell-command-projection.cts b/src/shell-command-projection.cts index ca762b3ed..761bd6167 100644 --- a/src/shell-command-projection.cts +++ b/src/shell-command-projection.cts @@ -253,6 +253,15 @@ export function isManagedHookCommand(commandText: unknown, opts: { surface?: str return false; } +/** + * Detect a `"$VAR"/rest` anchored hook-script token — a path whose leading + * shell variable is already double-quoted with the remainder left bare (the + * shape `projectLocalHookPrefix` emits for local installs, e.g. + * `"$CLAUDE_PROJECT_DIR"/.claude/hooks/gsd-x.js`). Such a token is ALREADY a + * valid, correctly-quoted shell argument and must never be re-quoted. + */ +const ANCHORED_HOOK_SCRIPT_TOKEN = /^"\$[A-Za-z_][A-Za-z0-9_]*"\//; + /** * Projection helper for legacy settings.json hook rewrites. * @@ -275,8 +284,20 @@ export function projectLegacySettingsHookCommand({ }): string | null { if (!absoluteRunner || !scriptPath) return null; const normalizedScriptPath = platform === 'win32' ? scriptPath.replace(/\\/g, '/') : scriptPath; + // #1693: a script path already carrying a `"$CLAUDE_PROJECT_DIR"`-anchored + // quoted prefix (local installs) is already a valid shell token — only the + // variable is quoted, the rest is bare. JSON.stringify-ing it on Windows + // yields `"\"$CLAUDE_PROJECT_DIR\"/..."` (escaped quotes inside an outer + // quote); node then receives an argument that *starts* with a `"`, treats it + // as relative, and dies with MODULE_NOT_FOUND. Emit anchored tokens verbatim; + // only bare absolute paths (which may contain spaces, e.g. "Program Files") + // need the JSON.stringify quoting. Scoped to win32: the non-Windows branch + // already preserves the caller's `scriptToken` (which is the bare anchored + // token for these inputs), so it never had the double-quote bug. const commandScriptToken = platform === 'win32' - ? JSON.stringify(normalizedScriptPath) + ? (ANCHORED_HOOK_SCRIPT_TOKEN.test(normalizedScriptPath) + ? normalizedScriptPath + : JSON.stringify(normalizedScriptPath)) : (scriptToken || JSON.stringify(normalizedScriptPath)); return projectShellCommandText({ runnerToken: absoluteRunner, @@ -596,6 +617,21 @@ function atomicRenameWithRetry(tmpPath: string, filePath: string): NodeJS.ErrnoE return renameErr; } +/** + * Drop-in replacement for `fs.renameSync(from, to)` that retries the transient + * Windows lock errnos (EPERM/EBUSY/EACCES — see DEFECT.WINDOWS-FS-OPS) a bounded + * number of times with a short backoff before rethrowing the final error. + * + * Idempotent on POSIX (the transient errnos do not occur), so callers retain + * identical semantics on macOS/Linux while gaining resilience on Windows where + * an antivirus scanner, indexer, or concurrent reader may briefly hold the + * target open. Enforced by local/require-fs-op-fallback (ADR-1703 Phase 6). + */ +export function retryRenameSync(fromPath: string, toPath: string): void { + const err = atomicRenameWithRetry(fromPath, toPath); + if (err !== null) throw err; +} + export function platformWriteSync(filePath: string, content: string, opts: { encoding?: BufferEncoding } = {}): void { const { content: normalized, encoding } = normalizeContent(filePath, content, opts); fs.mkdirSync(path.dirname(filePath), { recursive: true }); diff --git a/src/state.cts b/src/state.cts index f35b56879..ed072f0a1 100644 --- a/src/state.cts +++ b/src/state.cts @@ -20,7 +20,7 @@ const { escapeRegex, normalizePhaseName, extractPhaseToken } = phaseIdMod; // eslint-disable-next-line @typescript-eslint/no-require-imports import roadmapParserMod = require('./roadmap-parser.cjs'); const { getMilestoneInfo, getMilestonePhaseFilter, extractCurrentMilestone } = roadmapParserMod; -import { platformWriteSync, platformReadSync, platformEnsureDir } from './shell-command-projection.cjs'; +import { platformWriteSync, platformReadSync, platformEnsureDir, retryRenameSync } from './shell-command-projection.cjs'; // eslint-disable-next-line @typescript-eslint/no-require-imports import planningWorkspace = require('./planning-workspace.cjs'); const { planningDir, planningPaths } = planningWorkspace; @@ -1903,7 +1903,7 @@ function acquireStateLock(statePath: string, clock?: StateLockClock): string { // we must NOT fall through to a delete — back off and retry the create. const stolen = lockPath + '.stale-' + process.pid + '-' + clock.now() + '-' + (_stateStealSeq++); let renamed = false; - try { fs.renameSync(lockPath, stolen); renamed = true; } catch { /* another racer won */ } + try { retryRenameSync(lockPath, stolen); renamed = true; } catch { /* another racer won */ } if (renamed) { try { fs.rmSync(stolen, { force: true }); } catch { /* best-effort */ } // Successful steal — retry immediately to grab the just-freed lock. diff --git a/src/surface.cts b/src/surface.cts index 83e99d383..120944c6f 100644 --- a/src/surface.cts +++ b/src/surface.cts @@ -45,6 +45,9 @@ import runtimeArtifactLayout = require('./runtime-artifact-layout.cjs'); const { findInstallSourceRoot } = runtimeArtifactLayout; // eslint-disable-next-line @typescript-eslint/no-require-imports import runtimeArtifactConversion = require('./runtime-artifact-conversion.cjs'); +// eslint-disable-next-line @typescript-eslint/no-require-imports +import runtimeArtifactInstallPlan = require('./runtime-artifact-install-plan.cjs'); +const { assertDestWithinConfigHome } = runtimeArtifactInstallPlan; const SURFACE_FILE_NAME = '.gsd-surface.json'; @@ -341,7 +344,7 @@ function applySurface(runtimeConfigDir: string, layout: Layout, manifest: Map { for (const agent of ALL_AGENTS) { const content = fs.readFileSync(path.join(AGENTS_DIR, agent + '.md'), 'utf-8'); // Match actual heredoc commands (not references in anti-heredoc instruction) - const lines = content.split('\n'); + const lines = content.split(/\r?\n/); for (let i = 0; i < lines.length; i++) { const line = lines[i]; // Skip lines that are part of the anti-heredoc instruction or markdown code fences diff --git a/tests/atomic-write-coverage.test.cjs b/tests/atomic-write-coverage.test.cjs index 8bfb0bb7a..e5901f8ff 100644 --- a/tests/atomic-write-coverage.test.cjs +++ b/tests/atomic-write-coverage.test.cjs @@ -32,7 +32,7 @@ const libDir = path.resolve(__dirname, '..', 'gsd-core', 'bin', 'lib'); */ function findBareWrites(filePath) { const content = fs.readFileSync(filePath, 'utf-8'); - const lines = content.split('\n'); + const lines = content.split(/\r?\n/); const hits = []; for (let i = 0; i < lines.length; i++) { if (/\bfs\.writeFileSync\s*\(/.test(lines[i])) { diff --git a/tests/autonomous-allowed-tools.test.cjs b/tests/autonomous-allowed-tools.test.cjs index 2116d7a9e..8cfd1404c 100644 --- a/tests/autonomous-allowed-tools.test.cjs +++ b/tests/autonomous-allowed-tools.test.cjs @@ -28,7 +28,7 @@ describe('commands/gsd/autonomous.md allowed-tools', () => { // Parse the allowed-tools list items (lines starting with " - ") const toolLines = frontmatter - .split('\n') + .split(/\r?\n/) .filter((line) => /^\s+-\s+/.test(line)) .map((line) => line.replace(/^\s+-\s+/, '').trim()); diff --git a/tests/bug-130-finishinstall-opencode-testmode.test.cjs b/tests/bug-130-finishinstall-opencode-testmode.test.cjs index f0d698bad..353105bd8 100644 --- a/tests/bug-130-finishinstall-opencode-testmode.test.cjs +++ b/tests/bug-130-finishinstall-opencode-testmode.test.cjs @@ -23,6 +23,7 @@ const ROOT = path.join(__dirname, '..'); // the real ~/.config/opencode/ even if the guard is missing. const FAKE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-130-test-')); process.env.HOME = FAKE_HOME; +process.env.USERPROFILE = FAKE_HOME; // The opencode config dir that configureOpencodePermissions would use for a // global install when configDir=null: /.config/opencode/ diff --git a/tests/bug-1967-cache-invalidation.test.cjs b/tests/bug-1967-cache-invalidation.test.cjs index 389ade42d..fbda4cda4 100644 --- a/tests/bug-1967-cache-invalidation.test.cjs +++ b/tests/bug-1967-cache-invalidation.test.cjs @@ -81,7 +81,7 @@ describe('buildStateFrontmatter cache invalidation (#1967)', () => { // Read back and parse frontmatter to verify it reflects 2 phases, not 1 const result = fs.readFileSync(statePath, 'utf-8'); - const fmMatch = result.match(/^---\n([\s\S]*?)\n---/); + const fmMatch = result.match(/^---\r?\n([\s\S]*?)\r?\n---/); assert.ok(fmMatch, 'STATE.md should have frontmatter after writeStateMd'); const fm = fmMatch[1]; diff --git a/tests/bug-2410-stream-checkpoint-heartbeats.test.cjs b/tests/bug-2410-stream-checkpoint-heartbeats.test.cjs index dfae569c1..c43ed238d 100644 --- a/tests/bug-2410-stream-checkpoint-heartbeats.test.cjs +++ b/tests/bug-2410-stream-checkpoint-heartbeats.test.cjs @@ -50,39 +50,39 @@ describe('bug #2410: execute-phase emits checkpoint heartbeats', () => { test('workflow emits a wave-start heartbeat (A: wave-boundary checkpoint)', () => { assert.ok( - /\[checkpoint\][^\n]*wave \{N\}\/\{M\} starting/.test(workflow), + /\[checkpoint\][^\r\n]*wave \{N\}\/\{M\} starting/.test(workflow), 'workflow should emit a wave-start [checkpoint] marker before spawning agents' ); }); test('workflow emits a wave-complete heartbeat (A: wave-boundary checkpoint)', () => { assert.ok( - /\[checkpoint\][^\n]*wave \{N\}\/\{M\} complete/.test(workflow), + /\[checkpoint\][^\r\n]*wave \{N\}\/\{M\} complete/.test(workflow), 'workflow should emit a wave-complete [checkpoint] marker after spot-checks' ); }); test('workflow emits a plan-start heartbeat (B: plan-boundary checkpoint)', () => { assert.ok( - /\[checkpoint\][^\n]*plan \{plan_id\} starting/.test(workflow), + /\[checkpoint\][^\r\n]*plan \{plan_id\} starting/.test(workflow), 'workflow should emit a plan-start [checkpoint] marker before each Task() dispatch' ); }); test('workflow emits a plan-complete heartbeat (B: plan-boundary checkpoint)', () => { assert.ok( - /\[checkpoint\][^\n]*plan \{plan_id\} complete/.test(workflow), + /\[checkpoint\][^\r\n]*plan \{plan_id\} complete/.test(workflow), 'workflow should emit a plan-complete [checkpoint] marker after executor returns' ); }); test('workflow handles plan failure and checkpoint-gate heartbeats too', () => { assert.ok( - /\[checkpoint\][^\n]*plan \{plan_id\} failed/.test(workflow), + /\[checkpoint\][^\r\n]*plan \{plan_id\} failed/.test(workflow), 'workflow should emit a plan-failed [checkpoint] marker on executor error' ); assert.ok( - /\[checkpoint\][^\n]*plan \{plan_id\} checkpoint/.test(workflow), + /\[checkpoint\][^\r\n]*plan \{plan_id\} checkpoint/.test(workflow), 'workflow should emit a heartbeat when a plan returns a human-gate checkpoint' ); }); @@ -129,7 +129,7 @@ describe('bug #2410: execute-phase emits checkpoint heartbeats', () => { assert.ok(spawnIdx !== -1 && waitIdx !== -1, 'spawn and wait steps must exist'); const step3 = workflow.slice(spawnIdx, waitIdx); assert.ok( - /\[checkpoint\][^\n]*plan \{plan_id\} starting/.test(step3), + /\[checkpoint\][^\r\n]*plan \{plan_id\} starting/.test(step3), 'plan-start heartbeat should be emitted inside step 3 (spawn executor agents)' ); }); @@ -140,7 +140,7 @@ describe('bug #2410: execute-phase emits checkpoint heartbeats', () => { assert.ok(waitIdx !== -1 && hookIdx !== -1, 'wait + hook steps must exist'); const step4 = workflow.slice(waitIdx, hookIdx); assert.ok( - /\[checkpoint\][^\n]*plan \{plan_id\} complete/.test(step4), + /\[checkpoint\][^\r\n]*plan \{plan_id\} complete/.test(step4), 'plan-complete heartbeat should be emitted in step 4 (wait for agents)' ); @@ -149,7 +149,7 @@ describe('bug #2410: execute-phase emits checkpoint heartbeats', () => { assert.ok(reportIdx !== -1 && failureIdx !== -1, 'report + failure steps must exist'); const step6 = workflow.slice(reportIdx, failureIdx); assert.ok( - /\[checkpoint\][^\n]*wave \{N\}\/\{M\} complete/.test(step6), + /\[checkpoint\][^\r\n]*wave \{N\}\/\{M\} complete/.test(step6), 'wave-complete heartbeat should be emitted in step 6 (report completion)' ); }); diff --git a/tests/bug-2492-context-coverage-gate.test.cjs b/tests/bug-2492-context-coverage-gate.test.cjs index 7edc1b596..58dff07a1 100644 --- a/tests/bug-2492-context-coverage-gate.test.cjs +++ b/tests/bug-2492-context-coverage-gate.test.cjs @@ -95,8 +95,8 @@ describe('plan-phase decision-coverage gate (#2492)', () => { const snippet = md.slice(gateIdx, gateIdx + 800); // Accept either an inline `|| exit 1` or a `|| { ...; exit 1; }` group. const hasJqGuard = - /jq[^\n]*\.data\.passed\s*==\s*true/.test(snippet) || - /jq[^\n]*\(\.passed\s*\/\/\s*\.data\.passed\)\s*==\s*true/.test(snippet); + /jq[^\r\n]*\.data\.passed\s*==\s*true/.test(snippet) || + /jq[^\r\n]*\(\.passed\s*\/\/\s*\.data\.passed\)\s*==\s*true/.test(snippet); const hasExitOne = /\|\|\s*(?:exit\s+1|\{[\s\S]{0,200}?exit\s+1)/.test(snippet); assert.ok( hasJqGuard && hasExitOne, diff --git a/tests/bug-2516-inherit-model-execute-phase.test.cjs b/tests/bug-2516-inherit-model-execute-phase.test.cjs index aa963d6b3..699358976 100644 --- a/tests/bug-2516-inherit-model-execute-phase.test.cjs +++ b/tests/bug-2516-inherit-model-execute-phase.test.cjs @@ -69,7 +69,7 @@ describe('bug #2516: executor_model "inherit" must not be passed literally to Ta content.includes('omit `model=`') || content.includes('omit model=') ); - const lines = content.split('\n'); + const lines = content.split(/\r?\n/); const hasLiteralInheritInTask = lines.some(line => { if (!/model\s*=\s*["']inherit["']/.test(line)) return false; // Exclude instructional/explanatory lines that document what NOT to do diff --git a/tests/bug-2543-gsd-slash-namespace.test.cjs b/tests/bug-2543-gsd-slash-namespace.test.cjs index e4b7cba81..bd313bf9f 100644 --- a/tests/bug-2543-gsd-slash-namespace.test.cjs +++ b/tests/bug-2543-gsd-slash-namespace.test.cjs @@ -119,7 +119,7 @@ describe('slash-command namespace invariant (#3443)', () => { const violations = []; for (const file of allUserFacingFiles) { const src = fs.readFileSync(file, 'utf-8'); - const lines = src.split('\n'); + const lines = src.split(/\r?\n/); for (let i = 0; i < lines.length; i++) { if (retiredPattern.test(lines[i])) { violations.push(`${path.relative(ROOT, file)}:${i + 1}: ${lines[i].trim().slice(0, 80)}`); diff --git a/tests/bug-2643-skill-frontmatter-name.test.cjs b/tests/bug-2643-skill-frontmatter-name.test.cjs index 0ed5cbb6f..6b4b62e28 100644 --- a/tests/bug-2643-skill-frontmatter-name.test.cjs +++ b/tests/bug-2643-skill-frontmatter-name.test.cjs @@ -123,9 +123,9 @@ describe('skill frontmatter name parity (#2643 / #2808)', () => { const input = '---\nname: old\ndescription: test\n---\n\nBody.'; const result = convertClaudeCommandToClaudeSkill(input, 'gsd-execute-phase'); // Parse the frontmatter block structurally: extract the name: field value. - const frontmatterMatch = result.match(/^---\n([\s\S]*?)\n---/); + const frontmatterMatch = result.match(/^---\r?\n([\s\S]*?)\r?\n---/); assert.ok(frontmatterMatch, 'output must have a frontmatter block delimited by ---'); - const frontmatterLines = frontmatterMatch[1].split('\n'); + const frontmatterLines = frontmatterMatch[1].split(/\r?\n/); const nameEntry = frontmatterLines.find((l) => l.startsWith('name:')); assert.ok(nameEntry, 'frontmatter must contain a name: field'); const nameValue = nameEntry.replace(/^name:\s*/, '').trim(); @@ -185,7 +185,7 @@ describe('skill frontmatter name parity (#2643 / #2808)', () => { const skillDirName = 'gsd-' + base; const src = fs.readFileSync(path.join(COMMANDS_DIR, cmd), 'utf-8'); const out = convertClaudeCommandToClaudeSkill(src, skillDirName); - const m = out.match(/^---\nname:\s*(.+)$/m); + const m = out.match(/^---\r?\nname:\s*(.+)$/m); if (m) emitted.add(m[1].trim()); } diff --git a/tests/bug-2794-opencode-model-profile-overrides.test.cjs b/tests/bug-2794-opencode-model-profile-overrides.test.cjs index 97b53f648..d9e533e3f 100644 --- a/tests/bug-2794-opencode-model-profile-overrides.test.cjs +++ b/tests/bug-2794-opencode-model-profile-overrides.test.cjs @@ -44,17 +44,22 @@ describe('bug-2794: readGsdRuntimeProfileResolver resolves opencode tier overrid let projectDir; let homeDir; let origHome; + let origUP; beforeEach(() => { projectDir = makeTmp('proj'); homeDir = makeTmp('home'); origHome = process.env.HOME; + origUP = process.env.USERPROFILE; process.env.HOME = homeDir; + process.env.USERPROFILE = homeDir; }); afterEach(() => { if (origHome === undefined) delete process.env.HOME; else process.env.HOME = origHome; + if (origUP === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = origUP; cleanup(projectDir); cleanup(homeDir); }); @@ -105,20 +110,25 @@ describe('bug-2794: OpenCode agent install embeds model_profile_overrides model' let projectDir; let homeDir; let origHome; + let origUP; let origCwd; beforeEach(() => { projectDir = makeTmp('proj'); homeDir = makeTmp('home'); origHome = process.env.HOME; + origUP = process.env.USERPROFILE; origCwd = process.cwd(); process.env.HOME = homeDir; + process.env.USERPROFILE = homeDir; process.chdir(projectDir); }); afterEach(() => { if (origHome === undefined) delete process.env.HOME; else process.env.HOME = origHome; + if (origUP === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = origUP; process.chdir(origCwd); cleanup(projectDir); cleanup(homeDir); diff --git a/tests/bug-2808-skill-hyphen-name.test.cjs b/tests/bug-2808-skill-hyphen-name.test.cjs index db95ef051..28f885a61 100644 --- a/tests/bug-2808-skill-hyphen-name.test.cjs +++ b/tests/bug-2808-skill-hyphen-name.test.cjs @@ -34,9 +34,11 @@ const path = require('node:path'); const { cleanup, createTempDir } = require('./helpers.cjs'); const ROOT = path.join(__dirname, '..'); -const { convertClaudeCommandToClaudeSkill, installRuntimeArtifacts, skillFrontmatterName } = +const { convertClaudeCommandToClaudeSkill, skillFrontmatterName } = require(path.join(ROOT, 'bin', 'install.js')); +const { installRuntimeArtifacts } = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'install-engine.cjs')); + const { loadSkillsManifest, resolveProfile, @@ -81,9 +83,9 @@ describe('bug-2808: SKILL.md name: uses hyphen form', () => { const skillContent = convertClaudeCommandToClaudeSkill(src, skillDirName); // Parse frontmatter structurally: extract name: line from the --- block. - const fmMatch = skillContent.match(/^---\n([\s\S]*?)\n---/); + const fmMatch = skillContent.match(/^---\r?\n([\s\S]*?)\r?\n---/); assert.ok(fmMatch, `${cmd}: generated skill content must have a frontmatter block`); - const fmLines = fmMatch[1].split('\n'); + const fmLines = fmMatch[1].split(/\r?\n/); const nameEntry = fmLines.find((l) => l.startsWith('name:')); assert.ok(nameEntry, `${cmd}: generated SKILL.md is missing required name: field`); @@ -108,7 +110,7 @@ describe('bug-2808: SKILL.md name: uses hyphen form', () => { // gsd:sdk and gsd:tools are intentionally excluded: they are not slash commands // (no commands/gsd/sdk.md or tools.md exist), so the transformer correctly leaves // them alone. They are benign and should not trigger this assertion. - const bodyContent = skillContent.replace(/^---\n[\s\S]*?\n---\n?/, ''); + const bodyContent = skillContent.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, ''); const colonRefs = (bodyContent.match(/\bgsd:[a-z][a-z0-9-]*\b/g) || []) .filter(r => !/gsd:(sdk|tools)/.test(r)); assert.strictEqual( @@ -144,7 +146,7 @@ describe('bug-2808: SKILL.md name: uses hyphen form', () => { // Scan each line for Skill() calls using the colon form. // Parsing line-by-line is more precise than a multi-line regex // and avoids false positives from incidental matches in prose. - for (const line of stripped.split('\n')) { + for (const line of stripped.split(/\r?\n/)) { // Tolerate whitespace around the parenthesis, the `skill` keyword, // and the `=` so variants like `Skill( skill = "gsd:foo" )` are still // flagged. Without the `\s*` allowances, drift slips through this guard. @@ -213,9 +215,9 @@ describe('bug-2808: SKILL.md name: uses hyphen form', () => { const skillContent = fs.readFileSync(skillMdPath, 'utf-8'); // Scope the name: lookup to the YAML frontmatter block so a stray // `name:` line in the body cannot satisfy the assertion. - const fmMatch = skillContent.match(/^---\n([\s\S]*?)\n---/); + const fmMatch = skillContent.match(/^---\r?\n([\s\S]*?)\r?\n---/); assert.ok(fmMatch, `${relPath}: generated SKILL.md must include frontmatter`); - const nameLine = fmMatch[1].split('\n').find((l) => /^name:\s*/.test(l)); + const nameLine = fmMatch[1].split(/\r?\n/).find((l) => /^name:\s*/.test(l)); assert.ok(nameLine, `${relPath}: generated SKILL.md is missing name: frontmatter`); const name = nameLine.replace(/^name:\s*/, '').trim(); assert.ok(name.startsWith('gsd-'), `${relPath}: autocomplete name must start with gsd-, got ${name}`); diff --git a/tests/bug-2836-audit-open-summary-uat-drift.test.cjs b/tests/bug-2836-audit-open-summary-uat-drift.test.cjs index 600f9a97b..8d4487800 100644 --- a/tests/bug-2836-audit-open-summary-uat-drift.test.cjs +++ b/tests/bug-2836-audit-open-summary-uat-drift.test.cjs @@ -166,7 +166,7 @@ describe('bug #2836: workflows/help.md one-liner reconciliation', () => { // Locate the documented "Result: Creates ..." quick-task one-liner and // assert it references the per-task SUMMARY filename pattern, not bare // SUMMARY.md. We parse by line to avoid false positives elsewhere. - const resultLines = content.split('\n').filter(l => + const resultLines = content.split(/\r?\n/).filter(l => l.includes('Result: Creates') && l.includes('.planning/quick/') ); assert.ok(resultLines.length > 0, 'expected a quick-task Result line in help.md'); diff --git a/tests/bug-2973-profile-user-skills-path.test.cjs b/tests/bug-2973-profile-user-skills-path.test.cjs index bda1f9c3c..00d04c2c6 100644 --- a/tests/bug-2973-profile-user-skills-path.test.cjs +++ b/tests/bug-2973-profile-user-skills-path.test.cjs @@ -45,7 +45,8 @@ const { cleanup } = require('./helpers.cjs'); const ROOT = path.join(__dirname, '..'); const PROFILE_OUTPUT = path.join(ROOT, 'gsd-core', 'bin', 'lib', 'profile-output.cjs'); const WORKFLOW = path.join(ROOT, 'gsd-core', 'workflows', 'profile-user.md'); -const INSTALL = path.join(ROOT, 'bin', 'install.js'); + +const installEngine = require('../gsd-core/bin/lib/install-engine.cjs'); describe('Bug #2973: dev-preferences default writer path is skills/gsd-dev-preferences/SKILL.md', () => { test('exercise the writer in a subprocess with HOME pointed at a tmp dir; assert the artifact lands at the skills path', () => { @@ -112,7 +113,7 @@ describe('Bug #2973: profile-user.md confirmation message references the skills describe('Bug #2973: installer migrates existing legacy dev-preferences.md to skills/gsd-dev-preferences/SKILL.md', () => { test('migrateLegacyDevPreferencesToSkill is exported and writes to the skills path', () => { - const inst = require(INSTALL); + const inst = installEngine; // Module exports the migration helper for direct testing. // Note: this is the structural assertion — the helper exists with the // documented signature. End-to-end install testing is covered by @@ -124,7 +125,7 @@ describe('Bug #2973: installer migrates existing legacy dev-preferences.md to sk test('migration writes to skills/gsd-dev-preferences/SKILL.md when no skill exists yet', () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2973-mig-')); try { - const inst = require(INSTALL); + const inst = installEngine; const saved = new Map([['dev-preferences.md', '# my legacy preferences\n']]); const migrated = inst.migrateLegacyDevPreferencesToSkill(tmpDir, saved); assert.equal(migrated, true, 'expected migration to succeed when no SKILL.md exists'); @@ -139,7 +140,7 @@ describe('Bug #2973: installer migrates existing legacy dev-preferences.md to sk test('migration is a no-op when a SKILL.md already exists at the new location (do not clobber user-customized skill content)', () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2973-skip-')); try { - const inst = require(INSTALL); + const inst = installEngine; const skillDir = path.join(tmpDir, 'skills', 'gsd-dev-preferences'); const skillFile = path.join(skillDir, 'SKILL.md'); fs.mkdirSync(skillDir, { recursive: true }); @@ -172,7 +173,7 @@ describe('Bug #2973 (#3003 CR): installRuntimeArtifacts preserves user-owned gsd // Production install() does NOT call uninstallRuntimeArtifacts() first. // installRuntimeArtifacts → _copyStaged overlays only staged skill dirs; // gsd-dev-preferences (not in source) is left untouched. - const inst = require(INSTALL); + const inst = installEngine; const { loadSkillsManifest, resolveProfile } = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'install-profiles.cjs')); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2973-wipe-')); try { @@ -212,7 +213,7 @@ describe('Bug #2973 (#3003 CR): installRuntimeArtifacts preserves user-owned gsd // installRuntimeArtifacts() — the full uninstall+reinstall cycle. // uninstallRuntimeArtifacts removes all gsd-* entries; installRuntimeArtifacts // then writes fresh ones from source. - const inst = require(INSTALL); + const inst = installEngine; const { loadSkillsManifest, resolveProfile } = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'install-profiles.cjs')); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2973-wipe-shipped-')); try { diff --git a/tests/bug-3126-global-skills-base-runtime-path.test.cjs b/tests/bug-3126-global-skills-base-runtime-path.test.cjs index fe801df04..2a67ddc30 100644 --- a/tests/bug-3126-global-skills-base-runtime-path.test.cjs +++ b/tests/bug-3126-global-skills-base-runtime-path.test.cjs @@ -93,18 +93,18 @@ describe('bug #3126: runtime-homes getGlobalConfigDir — defaults', () => { describe('bug #3126: runtime-homes env-var overrides', () => { test('claude respects CLAUDE_CONFIG_DIR (was missing in old code)', () => { withEnv('CLAUDE_CONFIG_DIR', '/custom/claude', () => { - assert.strictEqual(getGlobalConfigDir('claude'), '/custom/claude'); + assert.strictEqual(String(getGlobalConfigDir('claude')).replace(/\\/g, '/'), '/custom/claude'); }); }); test('cursor respects CURSOR_CONFIG_DIR', () => { withEnv('CURSOR_CONFIG_DIR', '/custom/cursor', () => { - assert.strictEqual(getGlobalConfigDir('cursor'), '/custom/cursor'); + assert.strictEqual(String(getGlobalConfigDir('cursor')).replace(/\\/g, '/'), '/custom/cursor'); }); }); test('opencode respects OPENCODE_CONFIG_DIR', () => { withEnv('OPENCODE_CONFIG_DIR', '/custom/opencode', () => { withEnv('XDG_CONFIG_HOME', undefined, () => { - assert.strictEqual(getGlobalConfigDir('opencode'), '/custom/opencode'); + assert.strictEqual(String(getGlobalConfigDir('opencode')).replace(/\\/g, '/'), '/custom/opencode'); }); }); }); @@ -208,7 +208,7 @@ describe('bug #3126: runtime-homes getGlobalSkillDir', () => { describe('getGlobalConfigDir — explicitDir override and opencode/kilo file-path precedence', () => { // ── explicitDir override ────────────────────────────────────────────────── test('explicitDir absolute path is returned as-is (claude)', () => { - assert.strictEqual(getGlobalConfigDir('claude', '/tmp/x'), '/tmp/x'); + assert.strictEqual(String(getGlobalConfigDir('claude', '/tmp/x')).replace(/\\/g, '/'), '/tmp/x'); }); test('explicitDir with tilde is expanded (opencode)', () => { @@ -220,7 +220,7 @@ describe('getGlobalConfigDir — explicitDir override and opencode/kilo file-pat test('explicitDir wins even when OPENCODE_CONFIG_DIR is also set', () => { withEnv('OPENCODE_CONFIG_DIR', '/should/not/win', () => { - assert.strictEqual(getGlobalConfigDir('opencode', '/explicit/wins'), '/explicit/wins'); + assert.strictEqual(String(getGlobalConfigDir('opencode', '/explicit/wins')).replace(/\\/g, '/'), '/explicit/wins'); }); }); @@ -229,7 +229,7 @@ describe('getGlobalConfigDir — explicitDir override and opencode/kilo file-pat withEnv('OPENCODE_CONFIG_DIR', undefined, () => { withEnv('XDG_CONFIG_HOME', undefined, () => { withEnv('OPENCODE_CONFIG', '/home/u/cfg/opencode.json', () => { - assert.strictEqual(getGlobalConfigDir('opencode'), '/home/u/cfg'); + assert.strictEqual(String(getGlobalConfigDir('opencode')).replace(/\\/g, '/'), '/home/u/cfg'); }); }); }); @@ -238,7 +238,7 @@ describe('getGlobalConfigDir — explicitDir override and opencode/kilo file-pat test('opencode: OPENCODE_CONFIG_DIR takes precedence over OPENCODE_CONFIG', () => { withEnv('OPENCODE_CONFIG_DIR', '/dir/wins', () => { withEnv('OPENCODE_CONFIG', '/file/loses.json', () => { - assert.strictEqual(getGlobalConfigDir('opencode'), '/dir/wins'); + assert.strictEqual(String(getGlobalConfigDir('opencode')).replace(/\\/g, '/'), '/dir/wins'); }); }); }); @@ -247,7 +247,7 @@ describe('getGlobalConfigDir — explicitDir override and opencode/kilo file-pat withEnv('OPENCODE_CONFIG_DIR', undefined, () => { withEnv('OPENCODE_CONFIG', '/cfg/opencode.json', () => { withEnv('XDG_CONFIG_HOME', '/xdg/should/lose', () => { - assert.strictEqual(getGlobalConfigDir('opencode'), '/cfg'); + assert.strictEqual(String(getGlobalConfigDir('opencode')).replace(/\\/g, '/'), '/cfg'); }); }); }); @@ -271,7 +271,7 @@ describe('getGlobalConfigDir — explicitDir override and opencode/kilo file-pat withEnv('KILO_CONFIG_DIR', undefined, () => { withEnv('XDG_CONFIG_HOME', undefined, () => { withEnv('KILO_CONFIG', '/home/u/cfg/kilo.json', () => { - assert.strictEqual(getGlobalConfigDir('kilo'), '/home/u/cfg'); + assert.strictEqual(String(getGlobalConfigDir('kilo')).replace(/\\/g, '/'), '/home/u/cfg'); }); }); }); @@ -280,7 +280,7 @@ describe('getGlobalConfigDir — explicitDir override and opencode/kilo file-pat test('kilo: KILO_CONFIG_DIR takes precedence over KILO_CONFIG', () => { withEnv('KILO_CONFIG_DIR', '/dir/wins', () => { withEnv('KILO_CONFIG', '/file/loses.json', () => { - assert.strictEqual(getGlobalConfigDir('kilo'), '/dir/wins'); + assert.strictEqual(String(getGlobalConfigDir('kilo')).replace(/\\/g, '/'), '/dir/wins'); }); }); }); @@ -289,7 +289,7 @@ describe('getGlobalConfigDir — explicitDir override and opencode/kilo file-pat withEnv('KILO_CONFIG_DIR', undefined, () => { withEnv('KILO_CONFIG', '/cfg/kilo.json', () => { withEnv('XDG_CONFIG_HOME', '/xdg/should/lose', () => { - assert.strictEqual(getGlobalConfigDir('kilo'), '/cfg'); + assert.strictEqual(String(getGlobalConfigDir('kilo')).replace(/\\/g, '/'), '/cfg'); }); }); }); diff --git a/tests/bug-3130-update-npx-robust-invocation.test.cjs b/tests/bug-3130-update-npx-robust-invocation.test.cjs index 3ae34d453..6eea373db 100644 --- a/tests/bug-3130-update-npx-robust-invocation.test.cjs +++ b/tests/bug-3130-update-npx-robust-invocation.test.cjs @@ -32,7 +32,7 @@ const src = fs.readFileSync(UPDATE_WF, 'utf8'); test('bug #3130: update.md contains no bare npx invocations (cache-stale form)', () => { // Any occurrence of `npx -y @opengsd/gsd-core@` without `--package=` // is the stale form that triggers the two failure modes. - const stale = (src.match(/npx -y @opengsd\/gsd-core@\S+[^\n]*/g) || []); + const stale = (src.match(/npx -y @opengsd\/gsd-core@\S+[^\r\n]*/g) || []); assert.deepEqual( stale, [], diff --git a/tests/bug-3168-task-to-agent-rename.test.cjs b/tests/bug-3168-task-to-agent-rename.test.cjs index a99405d81..644be5ec1 100644 --- a/tests/bug-3168-task-to-agent-rename.test.cjs +++ b/tests/bug-3168-task-to-agent-rename.test.cjs @@ -33,9 +33,9 @@ function readMdFiles(dir, prefix) { } function extractFrontmatterTools(content) { - const fm = content.match(/^---\n([\s\S]*?)\n---/); + const fm = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); if (!fm) return []; - const toolsMatch = fm[1].match(/^allowed-tools:\s*\n((?:[ \t]+-[^\n]*\n?)*)/m) || + const toolsMatch = fm[1].match(/^allowed-tools:\s*\r?\n((?:[ \t]+-[^\n]*\n?)*)/m) || fm[1].match(/^tools:\s*(.+)$/m); if (!toolsMatch) return []; const toolsBlock = toolsMatch[1]; @@ -79,7 +79,7 @@ describe('#3168 — workflows: prose must use Agent( not Task( for dispatcher ca for (const wf of workflows) { test(`${wf.name}: must not contain dispatcher Task( calls`, () => { - const lines = wf.content.split('\n'); + const lines = wf.content.split(/\r?\n/); const violations = []; for (let i = 0; i < lines.length; i++) { const line = lines[i]; diff --git a/tests/bug-3290-intel-updater-layout-block.test.cjs b/tests/bug-3290-intel-updater-layout-block.test.cjs index 95c3d21d0..4496c2bf5 100644 --- a/tests/bug-3290-intel-updater-layout-block.test.cjs +++ b/tests/bug-3290-intel-updater-layout-block.test.cjs @@ -137,7 +137,7 @@ describe('bug #3290 — Group B: layout-detection verdict has no downstream cons const src = fs.readFileSync(file, 'utf-8'); if (src.includes('Layout detection returned')) { // Collect matching lines for the error message - const lines = src.split('\n') + const lines = src.split(/\r?\n/) .map((l, i) => ({ line: l, n: i + 1 })) .filter(({ line }) => line.includes('Layout detection returned')); matches.push({ rel, lines }); diff --git a/tests/bug-3413-shell-command-projection.test.cjs b/tests/bug-3413-shell-command-projection.test.cjs index bbc6a2541..8a793fb01 100644 --- a/tests/bug-3413-shell-command-projection.test.cjs +++ b/tests/bug-3413-shell-command-projection.test.cjs @@ -187,3 +187,92 @@ describe('bug #3439: shell projection module owns managed-hook policy and legacy ); }); }); + +describe('#1693 regression: Windows legacy-node rewrite must not double-quote a "$CLAUDE_PROJECT_DIR"-anchored local hook path', () => { + const winRunner = '"C:/Program Files/nodejs/node.exe"'; + + // WHY: a local-install hook path already carries a `"$CLAUDE_PROJECT_DIR"` + // anchored prefix (only the variable quoted, rest bare). On Windows the legacy + // rewrite previously JSON.stringify'd the whole token, yielding + // `"\"$CLAUDE_PROJECT_DIR\"/..."`. node then received an argument starting with + // a literal `"`, treated it as relative, and died with MODULE_NOT_FOUND — + // breaking every node managed hook at once (self-locking deadlock). + test('projectLegacySettingsHookCommand emits the anchored path verbatim, not re-quoted', () => { + const anchored = '"$CLAUDE_PROJECT_DIR"/.claude/hooks/gsd-context-monitor.js'; + const command = projectLegacySettingsHookCommand({ + absoluteRunner: winRunner, + scriptPath: anchored, + scriptToken: anchored, + platform: 'win32', + runtime: 'claude', + }); + assert.equal( + command, + '"C:/Program Files/nodejs/node.exe" "$CLAUDE_PROJECT_DIR"/.claude/hooks/gsd-context-monitor.js', + ); + assert.ok(!command.includes('\\"'), 'must not contain escaped double-quotes'); + }); + + // WHY: the fix must be surgical — a BARE absolute Windows path (no anchored + // prefix) can contain spaces ("Program Files") and still REQUIRES quoting. + test('projectLegacySettingsHookCommand still quotes a bare absolute Windows path', () => { + const abs = 'C:/Program Files App/.claude/hooks/gsd-context-monitor.js'; + const command = projectLegacySettingsHookCommand({ + absoluteRunner: winRunner, + scriptPath: abs, + scriptToken: JSON.stringify(abs), + platform: 'win32', + runtime: 'claude', + }); + assert.equal( + command, + '"C:/Program Files/nodejs/node.exe" "C:/Program Files App/.claude/hooks/gsd-context-monitor.js"', + ); + }); + + // WHY: the anchored short-circuit is scoped to win32. On POSIX the rewrite + // already preserved the caller's original `scriptToken` and never had the + // double-quote bug, so that behavior must be left byte-identical. scriptPath + // is anchored but scriptToken is a DISTINCT single-quoted value: if the + // win32 gate were removed, the anchored short-circuit would emit scriptPath + // and this assertion would fail — that is what pins the gate. + test('projectLegacySettingsHookCommand preserves the original scriptToken for anchored paths on POSIX', () => { + const command = projectLegacySettingsHookCommand({ + absoluteRunner: '"/usr/local/bin/node"', + scriptPath: '"$CLAUDE_PROJECT_DIR"/.claude/hooks/gsd-statusline.js', + scriptToken: "'/x/hooks/gsd-statusline.js'", + platform: 'linux', + runtime: 'claude', + }); + assert.equal(command, `"/usr/local/bin/node" '/x/hooks/gsd-statusline.js'`); + }); + + // WHY: end-to-end through the installer rewrite — the actual #2979 path that + // ran during the user's 1.5.0 -> 1.6.0 local update. Managed node hooks get + // the absolute runner + clean anchored path; a non-node-prefixed managed .sh + // hook (already correct) is left untouched. + test('rewriteLegacyManagedNodeHookCommands produces clean anchored node commands on Windows', () => { + const settings = { + hooks: { + PostToolUse: [ + { + hooks: [ + { command: 'node "$CLAUDE_PROJECT_DIR"/.claude/hooks/gsd-context-monitor.js' }, + ], + }, + ], + }, + }; + const changed = rewriteLegacyManagedNodeHookCommands(settings, winRunner, { + platform: 'win32', + runtime: 'claude', + }); + assert.equal(changed, true); + const rewritten = settings.hooks.PostToolUse[0].hooks[0].command; + assert.equal( + rewritten, + '"C:/Program Files/nodejs/node.exe" "$CLAUDE_PROJECT_DIR"/.claude/hooks/gsd-context-monitor.js', + ); + assert.ok(!rewritten.includes('\\"'), 'rewritten command must not contain escaped double-quotes'); + }); +}); diff --git a/tests/bug-3491-nested-git-worktree.test.cjs b/tests/bug-3491-nested-git-worktree.test.cjs index 2afbb6dbd..d31754f60 100644 --- a/tests/bug-3491-nested-git-worktree.test.cjs +++ b/tests/bug-3491-nested-git-worktree.test.cjs @@ -168,7 +168,7 @@ test('bug-3491: new-project.md gates `git init` on in_nested_subdir, not just ha // either gate the init on `in_nested_subdir`/worktree-root semantics or // drop the unconditional `git init` block entirely. const unconditionalInitPattern = - /\*\*If `has_git` is false:\*\* Initialize git:\s*\n+```bash\s*\ngit init\s*\n```/; + /\*\*If `has_git` is false:\*\* Initialize git:\s*\r?\n+```bash\s*\r?\ngit init\s*\r?\n```/; assert.ok( !unconditionalInitPattern.test(content), 'new-project.md must not run `git init` unconditionally on has_git=false (#3491). ' + diff --git a/tests/bug-3605-stale-research-insert-phase-agent-refs.test.cjs b/tests/bug-3605-stale-research-insert-phase-agent-refs.test.cjs index 6f3c46929..5c9f19e35 100644 --- a/tests/bug-3605-stale-research-insert-phase-agent-refs.test.cjs +++ b/tests/bug-3605-stale-research-insert-phase-agent-refs.test.cjs @@ -44,7 +44,7 @@ function listAgentFiles() { function scanForRetired(filePath) { const text = fs.readFileSync(filePath, 'utf-8'); - const lines = text.split('\n'); + const lines = text.split(/\r?\n/); const hits = []; for (let i = 0; i < lines.length; i++) { for (const cmd of RETIRED_COMMANDS) { diff --git a/tests/bug-3678-executor-commit-docs-respect.test.cjs b/tests/bug-3678-executor-commit-docs-respect.test.cjs index b70527457..f863a9300 100644 --- a/tests/bug-3678-executor-commit-docs-respect.test.cjs +++ b/tests/bug-3678-executor-commit-docs-respect.test.cjs @@ -150,7 +150,7 @@ describe('bug #3678 — executor must respect commit_docs:false', () => { ); const stagedAll = git(['diff', '--cached', '--name-only'], tmpDir); const stagedPlanning = stagedAll - .split('\n') + .split(/\r?\n/) .map(s => s.trim()) .filter(s => s.startsWith('.planning/')); assert.deepStrictEqual( @@ -178,7 +178,7 @@ describe('bug #3678 — executor must respect commit_docs:false', () => { test('checklist carve-out preserved for intentional skip', () => { const body = fs.readFileSync(EXECUTOR_AGENT, 'utf-8'); const checklistLine = body - .split('\n') + .split(/\r?\n/) .find(line => /Final metadata commit made/.test(line)); assert.ok( checklistLine, @@ -205,7 +205,7 @@ describe('bug #3678 — executor must respect commit_docs:false', () => { if (entry.isDirectory()) { walk(full); continue; } if (!entry.isFile() || !entry.name.endsWith('.md')) continue; const body = fs.readFileSync(full, 'utf-8'); - const lines = body.split('\n'); + const lines = body.split(/\r?\n/); const danger = lines.filter((line) => { if (!/git\s+add\s+(-f|--force)\b/.test(line)) return false; // Allow prohibition / warning sentences and code-fence prose that diff --git a/tests/bug-3683-command-cross-reference-invariant.test.cjs b/tests/bug-3683-command-cross-reference-invariant.test.cjs index 79496ddd7..1c361b7ff 100644 --- a/tests/bug-3683-command-cross-reference-invariant.test.cjs +++ b/tests/bug-3683-command-cross-reference-invariant.test.cjs @@ -19,7 +19,7 @@ function readKnownTargets() { } function stripFrontmatter(src) { - return src.replace(/^---\r?\n[\s\S]*?\n---\r?\n/, ''); + return src.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, ''); } // Word-boundary lookbehind matching fix-slash-commands.cjs buildColonPattern / buildPattern diff --git a/tests/bug-378-update-check-scoped-name.test.cjs b/tests/bug-378-update-check-scoped-name.test.cjs index 9baf23e4f..d73c6ba05 100644 --- a/tests/bug-378-update-check-scoped-name.test.cjs +++ b/tests/bug-378-update-check-scoped-name.test.cjs @@ -55,7 +55,7 @@ function workerCodeOnly() { const src = fs.readFileSync(WORKER_PATH, 'utf8'); return src .replace(/\/\*[\s\S]*?\*\//g, '') - .replace(/(^|[^:])\/\/[^\n]*/g, '$1'); + .replace(/(^|[^:])\/\/[^\r\n]*/g, '$1'); } describe('bug #378 / #498: update worker queries the scoped name via the seam', () => { diff --git a/tests/bug-503-update-agent-antigravity-detection.test.cjs b/tests/bug-503-update-agent-antigravity-detection.test.cjs index fd4a64587..b5ce266fd 100644 --- a/tests/bug-503-update-agent-antigravity-detection.test.cjs +++ b/tests/bug-503-update-agent-antigravity-detection.test.cjs @@ -88,13 +88,13 @@ describe('/gsd:update detects local Antigravity (.agent / .agents) installs (#50 test('execution_context classifier maps /.agents/ and /.agent/ paths to antigravity (update.md)', () => { const hasAgentsClassifierRule = - /\/\.agents\/[^\n]*->[^\n]*antigravity/.test(UPDATE_MD); + /\/\.agents\/[^\r\n]*->[^\r\n]*antigravity/.test(UPDATE_MD); assert.ok( hasAgentsClassifierRule, 'update.md classifier must map a `/.agents/` path to the `antigravity` runtime', ); const hasAgentClassifierRule = - /\/\.agent\/[^\n]*->[^\n]*antigravity/.test(UPDATE_MD); + /\/\.agent\/[^\r\n]*->[^\r\n]*antigravity/.test(UPDATE_MD); assert.ok( hasAgentClassifierRule, 'update.md classifier must still map a `/.agent/` path to the `antigravity` runtime (backward-compat)', @@ -108,7 +108,7 @@ describe('/gsd:update detects local Antigravity (.agent / .agents) installs (#50 // include both .agents (canonical, #791) and .agent (legacy, #503) or // stale indicators could linger. const runtimeDirLoops = UPDATE_MD - .split('\n') + .split(/\r?\n/) .filter((l) => /for dir in .*\.claude.*\.codex/.test(l)); assert.ok( runtimeDirLoops.length >= 1, diff --git a/tests/bug-619-codebase-drift-gate-shim.test.cjs b/tests/bug-619-codebase-drift-gate-shim.test.cjs index 55f9a33f8..919e95540 100644 --- a/tests/bug-619-codebase-drift-gate-shim.test.cjs +++ b/tests/bug-619-codebase-drift-gate-shim.test.cjs @@ -73,7 +73,7 @@ describe('bug #619 — codebase-drift-gate resolves gsd-tools via the runtime sh test('exactly one canonical launcher preamble, in the drift-check block, before any launcher call (#619)', () => { const content = readGate(); - const snippet = fs.readFileSync(SNIPPET_FILE, 'utf8').replace(/\n$/, ''); + const snippet = fs.readFileSync(SNIPPET_FILE, 'utf8').replace(/\r?\n$/, ''); // Count canonical preamble occurrences across the whole file (parity: exactly one). let count = 0; diff --git a/tests/bug-641-files-from-suite-token.test.cjs b/tests/bug-641-files-from-suite-token.test.cjs index a427fc527..327266de3 100644 --- a/tests/bug-641-files-from-suite-token.test.cjs +++ b/tests/bug-641-files-from-suite-token.test.cjs @@ -237,7 +237,7 @@ describe('bug #1329 — ci-prepare-test-scope fallback never emits a deleted fil assert.strictEqual(prep.status, 0, `prepare step failed: ${prep.stderr}`); const selected = fs.readFileSync(path.join(tmpDir, '.ci-selected-tests.txt'), 'utf8'); - for (const line of selected.split('\n').filter(Boolean)) { + for (const line of selected.split(/\r?\n/).filter(Boolean)) { const isSentinel = SUITE_SENTINELS.includes(line); assert.ok( isSentinel || fs.existsSync(path.join(tmpDir, line)), diff --git a/tests/bug-782-cline-skills-emission.test.cjs b/tests/bug-782-cline-skills-emission.test.cjs index 952beb95b..bd111ec7d 100644 --- a/tests/bug-782-cline-skills-emission.test.cjs +++ b/tests/bug-782-cline-skills-emission.test.cjs @@ -23,11 +23,12 @@ const { createTempDir, cleanup, captureConsole } = require('./helpers.cjs'); const { convertClaudeCommandToClineSkill, convertClaudeToCliineMarkdown, - installRuntimeArtifacts, install, _applyRuntimeRewrites, } = require('../bin/install.js'); +const { installRuntimeArtifacts } = require('../gsd-core/bin/lib/install-engine.cjs'); + const { resolveRuntimeArtifactLayout, } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); diff --git a/tests/bug-853-bg-dispatch-runtime-gating.test.cjs b/tests/bug-853-bg-dispatch-runtime-gating.test.cjs index 5fdad00e0..76ea5cd90 100644 --- a/tests/bug-853-bg-dispatch-runtime-gating.test.cjs +++ b/tests/bug-853-bg-dispatch-runtime-gating.test.cjs @@ -5,87 +5,225 @@ * dispatched Plan/Execute via Agent(run_in_background=true). On Claude Code a * backgrounded agent has no Agent/Task tool, so it cannot spawn the nested * subagents (worktree executors, plan-checker, verifier). The workflows must - * now resolve the runtime and run inline everywhere except Codex, which is the - * only supported runtime where a backgrounded agent can still nest subagents. + * now resolve dispatch capability from the registry (#1708) and run inline + * everywhere except runtimes where dispatch.background && dispatch.backgroundDispatch + * are both true (currently: codex, cursor). + * + * Phase B (#1708): the prose `RUNTIME === 'codex'` rule is graduated to a typed + * `gsd_run query dispatch-should-flatten` query backed by shouldFlattenDispatch() + * from host-integration.cjs and the documentation-sourced capability registry. */ const { describe, test } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); +const { createTempProject, cleanup: cleanupDir, runGsdTools } = require('./helpers.cjs'); const WORKFLOWS_DIR = path.join(__dirname, '..', 'gsd-core', 'workflows'); +// allow-test-rule: source-text-is-the-product (see #1708) const MANAGER = fs.readFileSync(path.join(WORKFLOWS_DIR, 'manager.md'), 'utf8'); +// allow-test-rule: source-text-is-the-product (see #1708) const AUTONOMOUS = fs.readFileSync(path.join(WORKFLOWS_DIR, 'autonomous.md'), 'utf8'); describe('bug-853 — manager/autonomous gate background dispatch by runtime', () => { - test('manager.md resolves the runtime before dispatching plan/execute', () => { - // Two dispatch sites (plan + execute), each must resolve the runtime. - const matches = MANAGER.match(/config-get runtime/g) || []; - assert.ok(matches.length >= 2, 'manager.md must resolve runtime for both plan and execute dispatch'); + test('manager.md resolves dispatch-should-flatten before dispatching plan/execute', () => { + // Two dispatch sites (plan + execute), each must use dispatch-should-flatten. + // allow-test-rule: source-text-is-the-product (see #1708) + const matches = MANAGER.match(/dispatch-should-flatten/g) || []; + assert.ok(matches.length >= 2, 'manager.md must use dispatch-should-flatten for both plan and execute dispatch'); }); test('manager.md documents why most runtimes cannot background-dispatch', () => { // Accept both old singular form (backgrounded agent has no) and new plural form (backgrounded agents have no) + // allow-test-rule: source-text-is-the-product (see #1708) assert.match(MANAGER, /backgrounded agents? ha(?:s|ve) no `Agent`\/`Task` tool/); }); - test('manager.md gates background dispatch on codex and runs plan/execute inline otherwise', () => { - // Codex takes the background path - assert.match(MANAGER, /If `RUNTIME` is `codex`[\s\S]{0,400}?run_in_background=true/); - // Inline is the default/else branch for plan — anchored on the explicit non-Codex label + test('manager.md gates background dispatch on FLATTEN=false and runs plan/execute inline otherwise', () => { + // Background path uses FLATTEN is false + // allow-test-rule: source-text-is-the-product (see #1708) + assert.match(MANAGER, /If `FLATTEN` is `false`[\s\S]{0,400}?run_in_background=true/); + // Inline is the default/else branch for plan — anchored on FLATTEN=true language (not runtime name) assert.match( MANAGER, - /Otherwise \(Claude Code or any other non-Codex runtime\)[\s\S]{0,400}?Skill\(skill="gsd-plan-phase"/, + /Otherwise[\s\S]{0,100}?`FLATTEN`[\s\S]{0,400}?Skill\(skill="gsd-plan-phase"/, ); - // Inline is the default/else branch for execute — anchored on the explicit non-Codex label + // Inline is the default/else branch for execute — anchored on FLATTEN=true language (not runtime name) assert.match( MANAGER, - /Otherwise \(Claude Code or any other non-Codex runtime\)[\s\S]{0,400}?Skill\(skill="gsd-execute-phase"/, + /Otherwise[\s\S]{0,100}?`FLATTEN`[\s\S]{0,400}?Skill\(skill="gsd-execute-phase"/, ); }); - test('manager.md compound actions only background plan/execute on Codex', () => { + test('manager.md compound action preamble uses FLATTEN language (not hardcoded runtime names)', () => { + // allow-test-rule: source-text-is-the-product (see #1708) const compoundActionSection = MANAGER.match( /### Compound Action \(background \+ inline\)[\s\S]*?Inline verification:/, ); assert.ok(compoundActionSection, 'manager.md must document compound action runtime dispatch'); + // Must gate on FLATTEN being false (not runtime name) assert.match( compoundActionSection[0], - /On Codex:[\s\S]{0,260}?Spawn all background agents first[\s\S]{0,220}?plan\/execute/, + /If `FLATTEN` is `false`[\s\S]{0,400}?Spawn all background agents first[\s\S]{0,300}?plan\/execute/, ); + // Otherwise / inline branch must reference FLATTEN being true assert.match( compoundActionSection[0], - /On Claude Code or any other non-Codex runtime:[\s\S]{0,260}?inline/, + /Otherwise[\s\S]{0,260}?`FLATTEN`[\s\S]{0,260}?`true`[\s\S]{0,260}?inline/, ); + // Must NOT still hardcode "On Codex:" in this section assert.doesNotMatch( compoundActionSection[0], - /On other runtimes:[\s\S]{0,260}?Spawn all background agents first/, + /\*\*On Codex:\*\*/, + ); + // Must NOT still hardcode "On Claude Code or any other non-Codex runtime:" + assert.doesNotMatch( + compoundActionSection[0], + /On Claude Code or any other non-Codex runtime:/, ); }); - test('autonomous.md gates interactive background dispatch by runtime', () => { - const autoRuntimeMatches = AUTONOMOUS.match(/config-get runtime/g) || []; - assert.ok(autoRuntimeMatches.length >= 2, 'autonomous.md must resolve runtime in both 3b (plan) and 3c (execute) interactive branches'); + test('autonomous.md gates interactive background dispatch using dispatch-should-flatten', () => { + // Two dispatch sites (3b plan + 3c execute), each must use dispatch-should-flatten. + // allow-test-rule: source-text-is-the-product (see #1708) + const autoFlattenMatches = AUTONOMOUS.match(/dispatch-should-flatten/g) || []; + assert.ok(autoFlattenMatches.length >= 2, 'autonomous.md must use dispatch-should-flatten in both 3b (plan) and 3c (execute) interactive branches'); // Accept both old singular form (backgrounded agent has no) and new plural form (backgrounded agents have no) assert.match(AUTONOMOUS, /backgrounded agents? ha(?:s|ve) no `Agent`\/`Task` tool/); }); - test('autonomous.md gates interactive background dispatch on codex; runs plan/execute inline otherwise', () => { - // Codex block: run_in_background=true appears within the codex branch and gsd-plan-phase is nearby - assert.match(AUTONOMOUS, /If `RUNTIME` is `codex`[\s\S]{0,1200}?run_in_background=true[\s\S]{0,600}?gsd-plan-phase/); - // Codex block: run_in_background=true appears within the codex branch and gsd-execute-phase is nearby - assert.match(AUTONOMOUS, /If `RUNTIME` is `codex`[\s\S]{0,3000}?run_in_background=true[\s\S]{0,200}?gsd-execute-phase/); - // Inline is the otherwise/else branch for plan — anchored on the explicit non-Codex label + test('autonomous.md gates interactive background dispatch on FLATTEN=false; runs plan/execute inline otherwise', () => { + // Background block: run_in_background=true appears within the FLATTEN=false branch and gsd-plan-phase is nearby + // allow-test-rule: source-text-is-the-product (see #1708) + assert.match(AUTONOMOUS, /If `FLATTEN` is `false`[\s\S]{0,1200}?run_in_background=true[\s\S]{0,600}?gsd-plan-phase/); + // Background block: run_in_background=true appears within the FLATTEN=false branch and gsd-execute-phase is nearby + assert.match(AUTONOMOUS, /If `FLATTEN` is `false`[\s\S]{0,3000}?run_in_background=true[\s\S]{0,200}?gsd-execute-phase/); + // Inline is the otherwise/else branch for plan — anchored on FLATTEN=true language (not runtime name) assert.match( AUTONOMOUS, - /Otherwise \(Claude Code or any other non-Codex runtime\)[\s\S]{0,400}?Skill\(skill="gsd-plan-phase"/, + /Otherwise[\s\S]{0,100}?`FLATTEN`[\s\S]{0,400}?Skill\(skill="gsd-plan-phase"/, ); - // Inline is the otherwise/else branch for execute — anchored on the explicit non-Codex label + // Inline is the otherwise/else branch for execute — anchored on FLATTEN=true language (not runtime name) assert.match( AUTONOMOUS, - /Otherwise \(Claude Code or any other non-Codex runtime\)[\s\S]{0,400}?Skill\(skill="gsd-execute-phase"/, + /Otherwise[\s\S]{0,100}?`FLATTEN`[\s\S]{0,400}?Skill\(skill="gsd-execute-phase"/, ); }); }); + +describe('dispatch-should-flatten query — behavioral', () => { + // #853 / #1708: The typed query replaces prose-level RUNTIME===codex checks. + // shouldFlattenDispatch returns false only when both dispatch.background AND + // dispatch.backgroundDispatch are true in the capability registry. + // + // Registry values (from host-integration-capability-matrix.md): + // codex: background=true, backgroundDispatch=true → shouldFlatten=false (may background) + // claude: background=true, backgroundDispatch=false → shouldFlatten=true (must inline) + // cursor: background=true, backgroundDispatch=true → shouldFlatten=false (may background) + // unknown: no entry → fail-closed → shouldFlatten=true (must inline) + + test('runtime=codex → shouldFlatten=false (background dispatch safe)', () => { + const tmpDir = createTempProject(); + try { + const result = runGsdTools(['query', 'dispatch-should-flatten', '--raw'], tmpDir, { + GSD_RUNTIME: 'codex', + }); + assert.ok(result.success, `Expected success, got error: ${result.error}`); + assert.strictEqual(result.output, 'false', `codex should return false (may background), got: ${result.output}`); + } finally { + cleanupDir(tmpDir); + } + }); + + test('runtime=claude → shouldFlatten=true (must inline)', () => { + const tmpDir = createTempProject(); + try { + const result = runGsdTools(['query', 'dispatch-should-flatten', '--raw'], tmpDir, { + GSD_RUNTIME: 'claude', + }); + assert.ok(result.success, `Expected success, got error: ${result.error}`); + assert.strictEqual(result.output, 'true', `claude should return true (must inline), got: ${result.output}`); + } finally { + cleanupDir(tmpDir); + } + }); + + test('runtime=cursor → shouldFlatten=false (background dispatch safe)', () => { + const tmpDir = createTempProject(); + try { + const result = runGsdTools(['query', 'dispatch-should-flatten', '--raw'], tmpDir, { + GSD_RUNTIME: 'cursor', + }); + assert.ok(result.success, `Expected success, got error: ${result.error}`); + assert.strictEqual(result.output, 'false', `cursor should return false (may background), got: ${result.output}`); + } finally { + cleanupDir(tmpDir); + } + }); + + test('unknown runtime → shouldFlatten=true (fail-closed → must inline)', () => { + // An unknown runtime has no registry entry → dispatch is null → fail-closed to true. + const tmpDir = createTempProject(); + try { + const result = runGsdTools(['query', 'dispatch-should-flatten', '--raw'], tmpDir, { + GSD_RUNTIME: 'unknown-runtime-xyz', + }); + // The query must succeed (exit 0) even for unknown runtimes — fail-closed not crash-closed. + assert.ok(result.success, `Expected success (fail-closed), got error: ${result.error}`); + assert.strictEqual(result.output, 'true', `unknown runtime should return true (fail-closed), got: ${result.output}`); + } finally { + cleanupDir(tmpDir); + } + }); + + test('--json flag returns structured { runtime, shouldFlatten, dispatch }', () => { + const tmpDir = createTempProject(); + try { + const result = runGsdTools(['query', 'dispatch-should-flatten', '--json'], tmpDir, { + GSD_RUNTIME: 'codex', + }); + assert.ok(result.success, `Expected success, got error: ${result.error}`); + let parsed; + try { + parsed = JSON.parse(result.output); + } catch { + assert.fail(`Expected valid JSON output, got: ${result.output}`); + } + assert.strictEqual(parsed.runtime, 'codex'); + assert.strictEqual(parsed.shouldFlatten, false); + assert.ok(parsed.dispatch !== null && typeof parsed.dispatch === 'object', 'dispatch should be an object'); + assert.strictEqual(parsed.dispatch.backgroundDispatch, true); + } finally { + cleanupDir(tmpDir); + } + }); + + test('config.runtime takes precedence when GSD_RUNTIME not set', () => { + // GSD_RUNTIME > config.runtime > 'claude' + // Write config.json with runtime=codex; no GSD_RUNTIME override. + const tmpDir = createTempProject(); + try { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ runtime: 'codex' }), + 'utf-8', + ); + // Override GSD_RUNTIME to '' (empty string) so any ambient value is cleared. + // resolveRuntimeNameFromCandidates treats empty string as absent (normalizes + // to '' which is falsy → skipped → falls through to config.runtime=codex). + // This is the only way to suppress an ambient GSD_RUNTIME since runGsdTools + // merges { ...process.env, ...TEST_ENV_BASE, ...env } — passing '' as the + // override overwrites the ambient value at the correct merge position. + const result = runGsdTools(['query', 'dispatch-should-flatten', '--raw'], tmpDir, { + GSD_RUNTIME: '', + }); + // config.runtime=codex with GSD_RUNTIME cleared → codex backgrounds → shouldFlatten=false + assert.ok(result.success, `Expected success, got error: ${result.error}`); + assert.strictEqual(result.output, 'false', `config.runtime=codex (GSD_RUNTIME cleared) should return false (may background), got: ${result.output}`); + } finally { + cleanupDir(tmpDir); + } + }); +}); diff --git a/tests/bug-924-claude-flat-skill-layout.test.cjs b/tests/bug-924-claude-flat-skill-layout.test.cjs index 9f4436184..2c4d43309 100644 --- a/tests/bug-924-claude-flat-skill-layout.test.cjs +++ b/tests/bug-924-claude-flat-skill-layout.test.cjs @@ -37,7 +37,7 @@ const os = require('node:os'); const ROOT = path.join(__dirname, '..'); const COMMANDS_GSD = path.join(ROOT, 'commands', 'gsd'); -const { installRuntimeArtifacts } = require('../bin/install.js'); +const { installRuntimeArtifacts } = require('../gsd-core/bin/lib/install-engine.cjs'); const { cleanup } = require('./helpers.cjs'); const { loadSkillsManifest, diff --git a/tests/bug-947-hermes-gsd-prefix.test.cjs b/tests/bug-947-hermes-gsd-prefix.test.cjs index e31c85aae..458b096dc 100644 --- a/tests/bug-947-hermes-gsd-prefix.test.cjs +++ b/tests/bug-947-hermes-gsd-prefix.test.cjs @@ -26,7 +26,7 @@ const fs = require('node:fs'); const path = require('node:path'); const os = require('node:os'); -const { installRuntimeArtifacts } = require('../bin/install.js'); +const { installRuntimeArtifacts } = require('../gsd-core/bin/lib/install-engine.cjs'); const { parseFrontmatter, cleanup } = require('./helpers.cjs'); const { loadSkillsManifest, diff --git a/tests/bug-978-milestone-complete-force.test.cjs b/tests/bug-978-milestone-complete-force.test.cjs index 8cd74c883..0ff7d5612 100644 --- a/tests/bug-978-milestone-complete-force.test.cjs +++ b/tests/bug-978-milestone-complete-force.test.cjs @@ -19,10 +19,13 @@ const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); * Build a fixture where the guard will fire: * - STATE.md has `milestone: ` so the guard's version-match check is * satisfied. - * - ROADMAP.md lists a `### Phase 999.1: Backlog Work` heading for that - * milestone, but there is NO on-disk phase directory for it. + * - ROADMAP.md lists a `### Phase 2: Real Work` heading for that milestone, but + * there is NO on-disk phase directory for it. * * This guarantees "unstarted phase" detection without touching any real phases. + * NOTE: the unstarted phase must be a REAL phase number — Phase 0 and Phase 999 + * are backlog/pre-milestone sentinels that are intentionally excluded from this + * guard (#1580), so they would not fire it. */ function makeGuardFixture(tmpDir, version) { // STATE.md with frontmatter milestone field matching the version @@ -32,10 +35,10 @@ function makeGuardFixture(tmpDir, version) { ); // ROADMAP.md — the heading must include the version so getMilestonePhaseFilter - // does not return missingExplicitVersion. Phase 999.1 has no on-disk dir. + // does not return missingExplicitVersion. Phase 2 has no on-disk dir. fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), - `# Roadmap ${version}\n\n### Phase 999.1: Backlog Work\n**Goal:** Not started\n`, + `# Roadmap ${version}\n\n### Phase 2: Real Work\n**Goal:** Not started\n`, ); } @@ -80,7 +83,7 @@ describe('bug-978: milestone complete --force overrides unstarted-phase guard', const output = JSON.parse(result.output); assert.strictEqual(output.version, 'v1.0'); - // Milestone entry should have been created even though phase 999.1 has no dir + // Milestone entry should have been created even though phase 2 has no dir assert.ok( fs.existsSync(path.join(tmpDir, '.planning', 'MILESTONES.md')), 'MILESTONES.md should have been created', diff --git a/tests/bug-kimi-path-layout-local-guard.test.cjs b/tests/bug-kimi-path-layout-local-guard.test.cjs index db97faa77..8396358b9 100644 --- a/tests/bug-kimi-path-layout-local-guard.test.cjs +++ b/tests/bug-kimi-path-layout-local-guard.test.cjs @@ -13,6 +13,10 @@ const { cleanup } = require('./helpers.cjs'); const { installerEnv } = require('./helpers/install-shared.cjs'); const ROOT = path.join(__dirname, '..'); + +// Cross-platform temp paths for test fixtures (avoids hardcoded /tmp) +const KIMI_CFG = path.join(os.tmpdir(), 'gsd-kimi-config-test').replace(/\\/g, '/'); +const XDG_HOME = path.join(os.tmpdir(), 'gsd-xdg-home-test'); const INSTALL_SCRIPT = path.join(ROOT, 'bin', 'install.js'); const { @@ -101,20 +105,21 @@ describe('Kimi runtime homes', () => { }); test('KIMI_CONFIG_DIR can select the brand-specific ~/.kimi-code root', () => { - withEnv({ KIMI_CONFIG_DIR: '/tmp/custom-kimi-code', XDG_CONFIG_HOME: undefined }, () => { - assert.strictEqual(getGlobalConfigDir('kimi'), '/tmp/custom-kimi-code'); + const customKimiDir = path.join(os.tmpdir(), 'custom-kimi-code'); + withEnv({ KIMI_CONFIG_DIR: customKimiDir, XDG_CONFIG_HOME: undefined }, () => { + assert.strictEqual(String(getGlobalConfigDir('kimi')).replace(/\\/g, '/'), customKimiDir.replace(/\\/g, '/')); assert.strictEqual( getGlobalSkillsBase('kimi'), - path.join('/tmp/custom-kimi-code', 'skills'), + path.join(customKimiDir, 'skills'), ); - assert.strictEqual(getGlobalDir('kimi'), '/tmp/custom-kimi-code'); + assert.strictEqual(String(getGlobalDir('kimi')).replace(/\\/g, '/'), customKimiDir.replace(/\\/g, '/')); }); }); test('XDG_CONFIG_HOME does not change Kimi default root', () => { const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-home-xdg-')); try { - withEnv({ KIMI_CONFIG_DIR: undefined, XDG_CONFIG_HOME: '/tmp/xdg-home', HOME: tmpHome, USERPROFILE: tmpHome }, () => { + withEnv({ KIMI_CONFIG_DIR: undefined, XDG_CONFIG_HOME: XDG_HOME, HOME: tmpHome, USERPROFILE: tmpHome }, () => { assert.strictEqual( getGlobalConfigDir('kimi'), path.join(tmpHome, '.config', 'agents'), @@ -166,9 +171,9 @@ describe('Kimi runtime homes', () => { describe('Kimi runtime artifact layout', () => { test('global layout stages Kimi skills and agents while local layout remains guarded', () => { - const globalLayout = resolveRuntimeArtifactLayout('kimi', '/tmp/kimi-config', 'global'); + const globalLayout = resolveRuntimeArtifactLayout('kimi', KIMI_CFG, 'global'); assert.strictEqual(globalLayout.runtime, 'kimi'); - assert.strictEqual(globalLayout.configDir, '/tmp/kimi-config'); + assert.strictEqual(String(globalLayout.configDir).replace(/\\/g, '/'), KIMI_CFG); assert.strictEqual(globalLayout.kinds.length, 2); assert.strictEqual(globalLayout.kinds[0].kind, 'skills'); assert.strictEqual(globalLayout.kinds[0].destSubpath, 'skills'); @@ -179,7 +184,7 @@ describe('Kimi runtime artifact layout', () => { assert.strictEqual(globalLayout.kinds[1].prefix, 'gsd'); assert.strictEqual(typeof globalLayout.kinds[1].stage, 'function'); - const localLayout = resolveRuntimeArtifactLayout('kimi', '/tmp/kimi-config', 'local'); + const localLayout = resolveRuntimeArtifactLayout('kimi', KIMI_CFG, 'local'); assert.strictEqual(localLayout.runtime, 'kimi'); assert.deepStrictEqual(localLayout.kinds, []); }); diff --git a/tests/capability-manifest-version.test.cjs b/tests/capability-manifest-version.test.cjs index 3427f74c5..aa757a298 100644 --- a/tests/capability-manifest-version.test.cjs +++ b/tests/capability-manifest-version.test.cjs @@ -76,6 +76,7 @@ function runtimeCap(overrides) { engines: { gsd: '>=1.6.0' }, runtime: { configHome: { kind: 'dot-home', name: '.demo', env: [] }, + localConfigDir: '.demo', configFormat: 'settings-json', artifactLayout: { global: [], local: [] }, commandStyle: 'slash-hyphen', @@ -86,6 +87,16 @@ function runtimeCap(overrides) { writesSharedSettings: false, permissionWriter: null, extendedHookEvents: [], + hostIntegration: { + embeddingMode: 'imperative', + commandSurface: 'slash-file', + dispatch: { namedDispatch: true, nested: true, maxDepth: -1, background: true, subagentToolkit: 'full', backgroundDispatch: false }, + modelMode: 'passive', + hookBus: 'host', + stateIO: 'filesystem', + transport: 'mcp', + runtime: 'node', + }, }, ...overrides, }; diff --git a/tests/capability-matrix-sync.test.cjs b/tests/capability-matrix-sync.test.cjs index f35e1f9dc..0c25087fe 100644 --- a/tests/capability-matrix-sync.test.cjs +++ b/tests/capability-matrix-sync.test.cjs @@ -31,8 +31,8 @@ describe('capability-matrix drift guard (ADR-1244 Phase 6)', () => { }); test('buildMatrix(registry) equals the committed file byte-for-byte (modulo line endings)', () => { - const generated = buildMatrix(registry).replace(/\r\n/g, '\n').replace(/\n+$/, '\n'); - const committed = fs.readFileSync(MATRIX, 'utf8').replace(/\r\n/g, '\n').replace(/\n+$/, '\n'); + const generated = buildMatrix(registry).replace(/\r\r?\n/g, '\n').replace(/\r?\n+$/, '\n'); + const committed = fs.readFileSync(MATRIX, 'utf8').replace(/\r\r?\n/g, '\n').replace(/\r?\n+$/, '\n'); assert.equal(committed, generated); }); @@ -53,7 +53,7 @@ describe('capability-matrix drift guard (ADR-1244 Phase 6)', () => { // rendered row reflects it. const shipPreGates = (registry.byLoopPoint['ship:pre'] && registry.byLoopPoint['ship:pre'].gates) || []; assert.ok(shipPreGates.some((g) => g.capId === 'security'), 'precondition: security registers a ship:pre gate in the registry'); - const securityRow = md.split('\n').find((l) => l.includes('`security`') && l.includes('|')); + const securityRow = md.split(/\r?\n/).find((l) => l.includes('`security`') && l.includes('|')); assert.ok(securityRow && securityRow.includes('`ship:pre`'), 'security row must list its real ship:pre extension point'); }); }); diff --git a/tests/capability-registry.test.cjs b/tests/capability-registry.test.cjs index 871f75d34..2ae04f743 100644 --- a/tests/capability-registry.test.cjs +++ b/tests/capability-registry.test.cjs @@ -1764,6 +1764,7 @@ describe('C3: role:runtime body validation', () => { tier: 'standard', requires: [], runtime: { configHome: { kind: 'dot-home', name: '.cursor', env: ['CURSOR_CONFIG_DIR'] }, + localConfigDir: '.cursor', configFormat: 'settings-json', artifactLayout: { global: [], local: [] }, commandStyle: 'slash-hyphen', @@ -1775,6 +1776,16 @@ describe('C3: role:runtime body validation', () => { writesSharedSettings: false, permissionWriter: null, extendedHookEvents: [], + hostIntegration: { + embeddingMode: 'declarative', + commandSurface: 'slash-file', + dispatch: { namedDispatch: true, nested: true, maxDepth: 2, background: false, subagentToolkit: 'full', backgroundDispatch: false }, + modelMode: 'passive', + hookBus: 'host', + stateIO: 'filesystem', + transport: 'mcp', + runtime: 'node', + }, }, }; @@ -3207,6 +3218,7 @@ function makeRuntimeCap(overrides) { requires: [], runtime: { configHome: { kind: 'dot-home', name: '.test-rt', env: ['TEST_RT_DIR'] }, + localConfigDir: '.test-rt', configFormat: 'settings-json', artifactLayout: { global: [], local: [] }, commandStyle: 'slash-hyphen', @@ -3218,6 +3230,16 @@ function makeRuntimeCap(overrides) { writesSharedSettings: true, permissionWriter: null, extendedHookEvents: [], + hostIntegration: { + embeddingMode: 'imperative', + commandSurface: 'slash-file', + dispatch: { namedDispatch: true, nested: true, maxDepth: -1, background: true, subagentToolkit: 'full', backgroundDispatch: false }, + modelMode: 'passive', + hookBus: 'host', + stateIO: 'filesystem', + transport: 'mcp', + runtime: 'node', + }, ...((overrides && overrides.runtime) ? overrides.runtime : {}), }, ...overrides, @@ -4272,6 +4294,7 @@ describe('ADR-857 phase 5f: cross-field consistency gate rejection tests (DEFECT requires: [], runtime: { configHome: { kind: 'dot-home', name: '.test-runtime', env: [] }, + localConfigDir: '.test-runtime', configFormat: 'settings-json', artifactLayout: { global: [], local: [] }, commandStyle: 'slash-hyphen', @@ -4283,6 +4306,16 @@ describe('ADR-857 phase 5f: cross-field consistency gate rejection tests (DEFECT writesSharedSettings: true, permissionWriter: null, extendedHookEvents: [], + hostIntegration: { + embeddingMode: 'imperative', + commandSurface: 'slash-file', + dispatch: { namedDispatch: true, nested: true, maxDepth: -1, background: true, subagentToolkit: 'full', backgroundDispatch: false }, + modelMode: 'passive', + hookBus: 'host', + stateIO: 'filesystem', + transport: 'mcp', + runtime: 'node', + }, }, }; if (overrides && typeof overrides === 'object') { @@ -5135,6 +5168,16 @@ describe('activationKey validation', () => { writesSharedSettings: false, permissionWriter: null, extendedHookEvents: [], + hostIntegration: { + embeddingMode: 'declarative', + commandSurface: 'slash-file', + dispatch: { namedDispatch: true, nested: true, maxDepth: 2, background: false, subagentToolkit: 'full', backgroundDispatch: false }, + modelMode: 'passive', + hookBus: 'host', + stateIO: 'filesystem', + transport: 'mcp', + runtime: 'node', + }, }, }; const errors = validateCapability(cap, 'cursor'); diff --git a/tests/check-update-config-dir.test.cjs b/tests/check-update-config-dir.test.cjs index dc7792f5c..0406723d2 100644 --- a/tests/check-update-config-dir.test.cjs +++ b/tests/check-update-config-dir.test.cjs @@ -87,7 +87,7 @@ describe('detectConfigDir runtime behavior (#1860)', () => { const hookSource = fs.readFileSync(CHECK_UPDATE_PATH, 'utf8'); // Extract detectConfigDir function body (from 'function detectConfigDir' to the closing brace) - const fnMatch = hookSource.match(/(function detectConfigDir\(baseDir\)\s*\{[\s\S]*?\n\})/); + const fnMatch = hookSource.match(/(function detectConfigDir\(baseDir\)\s*\{[\s\S]*?\r?\n\})/); assert.ok(fnMatch, 'should be able to extract detectConfigDir function from hook source'); const fnSource = fnMatch[1]; @@ -124,7 +124,7 @@ describe('detectConfigDir runtime behavior (#1860)', () => { fs.writeFileSync(path.join(openCodeVersionDir, 'VERSION'), '1.0.0\n'); const hookSource = fs.readFileSync(CHECK_UPDATE_PATH, 'utf8'); - const fnMatch = hookSource.match(/(function detectConfigDir\(baseDir\)\s*\{[\s\S]*?\n\})/); + const fnMatch = hookSource.match(/(function detectConfigDir\(baseDir\)\s*\{[\s\S]*?\r?\n\})/); assert.ok(fnMatch, 'should be able to extract detectConfigDir function from hook source'); const fnSource = fnMatch[1]; diff --git a/tests/ci-test-scope.test.cjs b/tests/ci-test-scope.test.cjs index 47e8eb803..57fd32344 100644 --- a/tests/ci-test-scope.test.cjs +++ b/tests/ci-test-scope.test.cjs @@ -431,7 +431,7 @@ describe('test.yml changes job contract (#837)', () => { test('changes job checkout step sets fetch-depth: 0 (required for three-dot diff merge-base)', () => { const workflowPath = path.join(WORKFLOWS_DIR, 'test.yml'); const text = fs.readFileSync(workflowPath, 'utf8'); - const lines = text.split('\n'); + const lines = text.split(/\r?\n/); // Locate the `changes:` job (two-space-indented top-level job key). const jobStart = lines.findIndex(l => /^ {2}changes:\s*$/.test(l)); @@ -547,6 +547,34 @@ describe('test-full shard matrix parity (#1212)', () => { }); }); +describe('golden-install-parity selection (#1691 drift guard)', () => { + // Regression: a src/*.cts-only edit recompiles bin/lib/*.cjs (changing installed + // hashes), but the scoped CI lane was not re-running golden-install-parity — + // causing golden fixtures to silently drift (#1691 milestone/roadmap cts change). + // Both the 'TS runtime sources' and 'installer and package layout' rules must now + // select tests/golden-install-parity.test.cjs. + + test('src/*.cts change selects golden-install-parity (TS runtime sources rule)', () => { + const result = scopeFor(['src/milestone.cts']); + assert.strictEqual(result.code_changed, true, + `expected code_changed=true for src/ change, got: ${JSON.stringify(result)}`); + assert.ok( + result.targeted_tests.includes('tests/golden-install-parity.test.cjs'), + `expected golden-install-parity in targeted_tests for src/*.cts change, got: ${JSON.stringify(result.targeted_tests)}`, + ); + }); + + test('bin/install.js change selects golden-install-parity (installer and package layout rule)', () => { + const result = scopeFor(['bin/install.js']); + assert.strictEqual(result.code_changed, true, + `expected code_changed=true for bin/ change, got: ${JSON.stringify(result)}`); + assert.ok( + result.targeted_tests.includes('tests/golden-install-parity.test.cjs'), + `expected golden-install-parity in targeted_tests for bin/install.js change, got: ${JSON.stringify(result.targeted_tests)}`, + ); + }); +}); + describe('code_changed=false implies clean output invariant', () => { // Fix 1: when code_changed is false, full_matrix, targeted_tests, windows_tests // must ALL be empty/false — even if a docs path coincidentally diff --git a/tests/claude-md.test.cjs b/tests/claude-md.test.cjs index 94683d8c3..9ecd947fe 100644 --- a/tests/claude-md.test.cjs +++ b/tests/claude-md.test.cjs @@ -230,7 +230,9 @@ describe('generate-claude-md skills section', () => { ); const originalHome = process.env.HOME; + const originalUserProfile = process.env.USERPROFILE; process.env.HOME = homeDir; + process.env.USERPROFILE = homeDir; try { const result = runGsdTools('generate-claude-md', tmpDir); @@ -241,7 +243,10 @@ describe('generate-claude-md skills section', () => { assert.ok(content.includes('Project Codex skill')); assert.ok(!content.includes('import-only')); } finally { - process.env.HOME = originalHome; + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + if (originalUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = originalUserProfile; cleanup(homeDir); } }); diff --git a/tests/claude-skills-migration.test.cjs b/tests/claude-skills-migration.test.cjs index fbfd8e0fb..e1573fed5 100644 --- a/tests/claude-skills-migration.test.cjs +++ b/tests/claude-skills-migration.test.cjs @@ -25,9 +25,12 @@ const ROOT = path.join(__dirname, '..'); const { convertClaudeCommandToClaudeSkill, writeManifest, +} = require(path.join(ROOT, 'bin', 'install.js')); + +const { installRuntimeArtifacts, uninstallRuntimeArtifacts, -} = require(path.join(ROOT, 'bin', 'install.js')); +} = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'install-engine.cjs')); const { loadSkillsManifest, diff --git a/tests/codebuddy-install.test.cjs b/tests/codebuddy-install.test.cjs index 83c00505f..eaf009b23 100644 --- a/tests/codebuddy-install.test.cjs +++ b/tests/codebuddy-install.test.cjs @@ -21,9 +21,10 @@ const { install, uninstall, writeManifest, - installRuntimeArtifacts, } = require('../bin/install.js'); +const { installRuntimeArtifacts } = require('../gsd-core/bin/lib/install-engine.cjs'); + const { getGlobalConfigDir } = require('../gsd-core/bin/lib/runtime-homes.cjs'); // ─── Profile resolution for installRuntimeArtifacts tests ──────────────────── diff --git a/tests/codex-config.test.cjs b/tests/codex-config.test.cjs index b08b3a1c2..f87290bba 100644 --- a/tests/codex-config.test.cjs +++ b/tests/codex-config.test.cjs @@ -116,7 +116,7 @@ function assertNoDraftRootKeys(content) { function assertUsesOnlyEol(content, eol) { if (eol === '\r\n') { assert.ok(content.includes('\r\n'), 'contains CRLF line endings'); - assert.ok(!content.replace(/\r\n/g, '').includes('\n'), 'does not contain bare LF line endings'); + assert.ok(!content.replace(/\r\r?\n/g, '').includes('\n'), 'does not contain bare LF line endings'); return; } assert.ok(!content.includes('\r\n'), 'does not contain CRLF line endings'); @@ -124,7 +124,7 @@ function assertUsesOnlyEol(content, eol) { function assertNoCodexBareGsdToolsInvocation(content, label) { const patterns = [ - /(^|\n)[ \t]*gsd-tools\s/, + /(^|\r?\n)[ \t]*gsd-tools\s/, /\$\(\s*gsd-tools\s/, /`\s*gsd-tools\s/, /(?:&&|\|\||[;|])\s*gsd-tools\s/, @@ -1379,7 +1379,7 @@ describe('mergeCodexConfig', () => { assert.ok(content.includes('[agents.custom-agent]'), 'preserves non-GSD agent section'); assert.strictEqual(gsdStructCount, 1, 'keeps exactly one [agents.gsd-executor] struct entry'); assert.strictEqual(markerCount, 1, 'adds exactly one marker block'); - assert.ok(!/\n{3,}# GSD Agent Configuration/.test(content), 'does not leave extra blank lines before marker block'); + assert.ok(!/\r?\n{3,}# GSD Agent Configuration/.test(content), 'does not leave extra blank lines before marker block'); }); test('idempotent: re-merge produces same result', () => { @@ -1693,11 +1693,11 @@ describe('codex features section safety', () => { // causes "invalid type: string, expected a boolean in features" const configContent = `[features]\ncodex_hooks = true\n\nmodel = "gpt-5.4"\nmodel_reasoning_effort = "medium"\n\n[agents.gsd-executor]\ndescription = "test"\n`; - const featuresMatch = configContent.match(/\[features\]\n([\s\S]*?)(?=\n\[|$)/); + const featuresMatch = configContent.match(/\[features\]\r?\n([\s\S]*?)(?=\n\[|$)/); assert.ok(featuresMatch, 'features section found'); const featuresBody = featuresMatch[1]; - const nonBooleanKeys = featuresBody.split('\n') + const nonBooleanKeys = featuresBody.split(/\r?\n/) .filter(line => line.match(/^\s*\w+\s*=/) && !line.match(/=\s*(true|false)\s*(#.*)?$/)) .map(line => line.trim()); @@ -1709,9 +1709,9 @@ describe('codex features section safety', () => { test('boolean keys under [features] are NOT flagged', () => { const configContent = `[features]\ncodex_hooks = true\nmulti_agent = false\n`; - const featuresMatch = configContent.match(/\[features\]\n([\s\S]*?)(?=\n\[|$)/); + const featuresMatch = configContent.match(/\[features\]\r?\n([\s\S]*?)(?=\n\[|$)/); const featuresBody = featuresMatch[1]; - const nonBooleanKeys = featuresBody.split('\n') + const nonBooleanKeys = featuresBody.split(/\r?\n/) .filter(line => line.match(/^\s*\w+\s*=/) && !line.match(/=\s*(true|false)\s*(#.*)?$/)) .map(line => line.trim()); @@ -1793,7 +1793,7 @@ describe('Codex install hook configuration (e2e)', () => { const content = readCodexConfig(codexHome); const agentsDir = path.join(codexHome, 'agents').replace(/\\/g, '/'); // All config_file values should use absolute paths - const configFileLines = content.split('\n').filter(l => l.startsWith('config_file = ')); + const configFileLines = content.split(/\r?\n/).filter(l => l.startsWith('config_file = ')); assert.ok(configFileLines.length > 0, 'has config_file entries'); for (const line of configFileLines) { assert.ok(line.includes(agentsDir), `absolute path in: ${line}`); @@ -1831,10 +1831,10 @@ describe('Codex install hook configuration (e2e)', () => { assert.ok(reasoningIndex < featuresIndex, 'model_reasoning_effort= relocated before [features]'); // [features] should only contain boolean keys - const featuresMatch = content.match(/\[features\]\n([\s\S]*?)(?=\n\[|$)/); + const featuresMatch = content.match(/\[features\]\r?\n([\s\S]*?)(?=\n\[|$)/); assert.ok(featuresMatch, 'features section found'); const featuresBody = featuresMatch[1]; - const nonBooleanKeys = featuresBody.split('\n') + const nonBooleanKeys = featuresBody.split(/\r?\n/) .filter(line => line.match(/^\s*\w+\s*=/) && !line.match(/=\s*(true|false)\s*(#.*)?$/)); assert.strictEqual(nonBooleanKeys.length, 0, 'no non-boolean keys under [features]'); @@ -1895,10 +1895,10 @@ describe('Codex install hook configuration (e2e)', () => { assert.ok(reasoningIndex < featuresIndex, 'model_reasoning_effort= stays before [features]'); // [features] should only contain boolean keys - const featuresMatch = content.match(/\[features\]\n([\s\S]*?)(?=\n\[|$)/); + const featuresMatch = content.match(/\[features\]\r?\n([\s\S]*?)(?=\n\[|$)/); assert.ok(featuresMatch, 'features section found'); const featuresBody = featuresMatch[1]; - const nonBooleanKeys = featuresBody.split('\n') + const nonBooleanKeys = featuresBody.split(/\r?\n/) .filter(line => line.match(/^\s*\w+\s*=/) && !line.match(/=\s*(true|false)\s*(#.*)?$/)); assert.strictEqual(nonBooleanKeys.length, 0, 'no non-boolean keys under [features]'); @@ -2572,7 +2572,7 @@ describe('Codex uninstall symmetry for hook-enabled configs', () => { runCodexInstall(codexHome); const cleaned = stripGsdFromCodexConfig(readCodexConfig(codexHome)); - assert.strictEqual(cleaned, initialContent, `preserves short-circuited root features assignment: ${initialContent.split('\n')[0]}`); + assert.strictEqual(cleaned, initialContent, `preserves short-circuited root features assignment: ${initialContent.split(/\r?\n/)[0]}`); cleanup(codexHome); fs.mkdirSync(codexHome, { recursive: true }); @@ -2588,7 +2588,7 @@ describe('Codex uninstall symmetry for hook-enabled configs', () => { '[model]', 'name = "o3"', '', - ].join('\r\n').replace(/^# first line wins\r\n/, '# first line wins\n'); + ].join('\r\n').replace(/^# first line wins\r\r?\n/, '# first line wins\n'); writeCodexConfig(codexHome, initialContent); runCodexInstall(codexHome); diff --git a/tests/commit-docs-bypass.test.cjs b/tests/commit-docs-bypass.test.cjs index aef33410e..2ac5328bc 100644 --- a/tests/commit-docs-bypass.test.cjs +++ b/tests/commit-docs-bypass.test.cjs @@ -22,7 +22,7 @@ describe('commit_docs bypass guard (#1783)', () => { test('execute-phase.md: every git add .planning/ has a commit_docs guard', () => { const content = fs.readFileSync(EXECUTE_PHASE_PATH, 'utf-8'); - const lines = content.split('\n'); + const lines = content.split(/\r?\n/); for (let i = 0; i < lines.length; i++) { if (/git add\b.*\.planning\//.test(lines[i])) { @@ -39,7 +39,7 @@ describe('commit_docs bypass guard (#1783)', () => { test('quick.md: every git add .planning/ has a commit_docs guard', () => { const content = fs.readFileSync(QUICK_PATH, 'utf-8'); - const lines = content.split('\n'); + const lines = content.split(/\r?\n/); for (let i = 0; i < lines.length; i++) { if (/git add\b.*\.planning\//.test(lines[i])) { @@ -55,7 +55,7 @@ describe('commit_docs bypass guard (#1783)', () => { test('quick.md: git add ${file_list} has a commit_docs guard for .planning/ filtering', () => { const content = fs.readFileSync(QUICK_PATH, 'utf-8'); - const lines = content.split('\n'); + const lines = content.split(/\r?\n/); // Find the line(s) that do `git add ${file_list}` — this variable // includes .planning/STATE.md so it needs a commit_docs guard too @@ -82,7 +82,7 @@ describe('commit_docs bypass guard (#1783)', () => { const content = fs.readFileSync(wf.path, 'utf-8'); // Find all occurrences of git add that reference .planning/ - const regex = /git add\b[^\n]*\.planning\//g; + const regex = /git add\b[^\r\n]*\.planning\//g; let match; while ((match = regex.exec(content)) !== null) { // Get the 500-char window before this match diff --git a/tests/config-field-docs.test.cjs b/tests/config-field-docs.test.cjs index 490c8af64..ad9fdc72a 100644 --- a/tests/config-field-docs.test.cjs +++ b/tests/config-field-docs.test.cjs @@ -64,7 +64,7 @@ describe('config-field-docs', () => { // Extract CONFIG_DEFAULTS keys from config-loader.cjs source (moved from core.cjs by ADR-857 phase 2e) const coreSource = fs.readFileSync(CORE_PATH, 'utf-8'); const defaultsMatch = coreSource.match( - /const CONFIG_DEFAULTS\s*=\s*\{([\s\S]*?)\n\};/ + /const CONFIG_DEFAULTS\s*=\s*\{([\s\S]*?)\r?\n\};/ ); assert.ok(defaultsMatch, 'Could not find CONFIG_DEFAULTS in config-loader.cjs'); @@ -302,7 +302,7 @@ describe('CONFIGURATION.md parity (#1216)', () => { test('settings-advanced.md parse-default list must NOT show subagent_timeout default 600 (#1216)', () => { // Line 53 regression: the parse-default list item must use 300000, not 600 assert.ok( - !(/`workflow\.subagent_timeout`[^\n]*default:[^\n]*`?600`?/.test(settingsAdvancedContent)), + !(/`workflow\.subagent_timeout`[^\r\n]*default:[^\n]*`?600`?/.test(settingsAdvancedContent)), 'settings-advanced.md must NOT list subagent_timeout default as 600 (stale seconds default)' ); }); diff --git a/tests/copilot-install.test.cjs b/tests/copilot-install.test.cjs index eaa6b8eca..40945f10d 100644 --- a/tests/copilot-install.test.cjs +++ b/tests/copilot-install.test.cjs @@ -44,13 +44,14 @@ const { writeCopilotHookConfig, writeManifest, reportLocalPatches, - installRuntimeArtifacts, runtimeMap, allRuntimes, parseRuntimeInput, buildRuntimePromptText, } = require('../bin/install.js'); +const { installRuntimeArtifacts } = require('../gsd-core/bin/lib/install-engine.cjs'); + const { getGlobalConfigDir } = require('../gsd-core/bin/lib/runtime-homes.cjs'); // ─── Profile resolution for installRuntimeArtifacts tests ──────────────────── diff --git a/tests/dispatch/trace-correlation.test.cjs b/tests/dispatch/trace-correlation.test.cjs index 6c8a8ee1c..45d1e0aa7 100644 --- a/tests/dispatch/trace-correlation.test.cjs +++ b/tests/dispatch/trace-correlation.test.cjs @@ -47,7 +47,7 @@ function makeManifest() { function readJsonl(filePath) { const raw = fs.readFileSync(filePath, 'utf8').trim(); if (!raw) return []; - return raw.split('\n').map(line => JSON.parse(line)); + return raw.split(/\r?\n/).map(line => JSON.parse(line)); } // ─── Shared state for the test group ───────────────────────────────────────── diff --git a/tests/edge-probe-docs-fixtures.test.cjs b/tests/edge-probe-docs-fixtures.test.cjs index 3b848bca6..61654e665 100644 --- a/tests/edge-probe-docs-fixtures.test.cjs +++ b/tests/edge-probe-docs-fixtures.test.cjs @@ -24,7 +24,7 @@ const specTemplatePath = path.join(__dirname, '..', 'gsd-core', 'templates', 'sp // The \n? before the closing fence allows blocks whose closing fence has no preceding newline // (fixes the silent-skip bug where a trailing-fence-with-no-newline was not matched). function taggedJsonBlocks(md) { - const re = /```json edge-probe:([^\n]+)\n([\s\S]*?)\n?```/g; + const re = /```json edge-probe:([^\r\n]+)\r?\n([\s\S]*?)\r?\n?```/g; const out = {}; let m; while ((m = re.exec(md))) out[m[1].trim()] = m[2]; diff --git a/tests/enh-773-codex-exec-automation-flags.test.cjs b/tests/enh-773-codex-exec-automation-flags.test.cjs index 68779f187..ec187951d 100644 --- a/tests/enh-773-codex-exec-automation-flags.test.cjs +++ b/tests/enh-773-codex-exec-automation-flags.test.cjs @@ -19,7 +19,7 @@ describe('enh-773: automated codex exec invocations include --ephemeral and --da // probe (`codex exec --help | grep …`) is not an automation invocation, so it // is excluded from the per-invocation flag assertions below. const codexExecLines = workflow - .split('\n') + .split(/\r?\n/) .filter((line) => line.includes('codex exec') && !line.includes('codex exec --help')); test('review.md contains at least one codex exec invocation', () => { @@ -43,7 +43,7 @@ describe('enh-773: automated codex exec invocations include --ephemeral and --da // be probed (`codex exec --help | grep`) and applied via $CODEX_BYPASS_FLAG so // older installs do not fail with "unexpected argument" (a silent empty review). assert.ok( - /codex exec --help[^\n]*grep[^\n]*--dangerously-bypass-hook-trust/.test(workflow), + /codex exec --help[^\r\n]*grep[^\r\n]*--dangerously-bypass-hook-trust/.test(workflow), 'review.md must capability-probe --dangerously-bypass-hook-trust via `codex exec --help | grep`' ); assert.ok( diff --git a/tests/enh-789-codebuddy-commands.test.cjs b/tests/enh-789-codebuddy-commands.test.cjs index 56ca77e69..dd07ed44c 100644 --- a/tests/enh-789-codebuddy-commands.test.cjs +++ b/tests/enh-789-codebuddy-commands.test.cjs @@ -38,11 +38,14 @@ const path = require('node:path'); const { createTempDir, cleanup } = require('./helpers.cjs'); const { - installRuntimeArtifacts, - uninstallRuntimeArtifacts, convertClaudeCommandToCodebuddyCommand, convertClaudeCommandToCodebuddySkill, } = require('../bin/install.js'); + +const { + installRuntimeArtifacts, + uninstallRuntimeArtifacts, +} = require('../gsd-core/bin/lib/install-engine.cjs'); const { resolveRuntimeArtifactLayout } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); const { loadSkillsManifest, resolveProfile } = require('../gsd-core/bin/lib/install-profiles.cjs'); @@ -53,11 +56,11 @@ const RESOLVED_CORE = resolveProfile({ modes: ['core'], manifest: MANIFEST }); // ─── Layout contract ───────────────────────────────────────────────────────── describe('enh-789 — codebuddy layout has commands + skills kinds', () => { - test('resolveRuntimeArtifactLayout codebuddy returns 2 kinds', () => { + test('resolveRuntimeArtifactLayout codebuddy returns 3 kinds (ADR-1235 §1 agents cutover)', () => { const layout = resolveRuntimeArtifactLayout('codebuddy', '/tmp/fake-codebuddy-dir'); - assert.strictEqual(layout.kinds.length, 2, 'codebuddy must have exactly 2 artifact kinds'); + assert.strictEqual(layout.kinds.length, 3, 'codebuddy must have exactly 3 artifact kinds (commands + skills + agents)'); const kindNames = layout.kinds.map(k => k.kind).sort(); - assert.deepStrictEqual(kindNames, ['commands', 'skills']); + assert.deepStrictEqual(kindNames, ['agents', 'commands', 'skills']); }); test('codebuddy commands kind targets commands/ with gsd- prefix', () => { diff --git a/tests/enh-790-augment-commands.test.cjs b/tests/enh-790-augment-commands.test.cjs index 95f772000..8ab26b342 100644 --- a/tests/enh-790-augment-commands.test.cjs +++ b/tests/enh-790-augment-commands.test.cjs @@ -21,7 +21,7 @@ const path = require('node:path'); const { createTempDir, cleanup } = require('./helpers.cjs'); -const { installRuntimeArtifacts } = require('../bin/install.js'); +const { installRuntimeArtifacts, uninstallRuntimeArtifacts } = require('../gsd-core/bin/lib/install-engine.cjs'); const { resolveRuntimeArtifactLayout } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); const { loadSkillsManifest, resolveProfile } = require('../gsd-core/bin/lib/install-profiles.cjs'); @@ -31,12 +31,12 @@ const RESOLVED_CORE = resolveProfile({ modes: ['core'], manifest: MANIFEST }); // ─── Layout contract ───────────────────────────────────────────────────────── -describe('enh-790 — augment layout has commands + skills kinds', () => { - test('resolveRuntimeArtifactLayout augment returns 2 kinds', () => { +describe('enh-790 — augment layout has commands + skills + agents kinds', () => { + test('resolveRuntimeArtifactLayout augment returns 3 kinds', () => { const layout = resolveRuntimeArtifactLayout('augment', '/tmp/fake-augment-dir'); - assert.strictEqual(layout.kinds.length, 2, 'augment must have exactly 2 artifact kinds'); + assert.strictEqual(layout.kinds.length, 3, 'augment must have exactly 3 artifact kinds'); const kindNames = layout.kinds.map(k => k.kind).sort(); - assert.deepStrictEqual(kindNames, ['commands', 'skills']); + assert.deepStrictEqual(kindNames, ['agents', 'commands', 'skills']); }); test('augment commands kind targets commands/ with gsd- prefix', () => { @@ -177,7 +177,7 @@ describe('enh-790 — uninstallRuntimeArtifacts removes augment commands', () => const configDir = createTempDir('gsd-enh790-uninstall-'); t.after(() => cleanup(configDir)); - const { uninstallRuntimeArtifacts } = require('../bin/install.js'); + // uninstallRuntimeArtifacts is imported from install-engine.cjs at the top of this file // Pre-create: a GSD command + a user-owned command const commandsDir = path.join(configDir, 'commands'); diff --git a/tests/execute-phase-wave.test.cjs b/tests/execute-phase-wave.test.cjs index 8aaf9f58c..d4ae1cbba 100644 --- a/tests/execute-phase-wave.test.cjs +++ b/tests/execute-phase-wave.test.cjs @@ -31,7 +31,7 @@ describe('execute-phase command: --wave flag', () => { test('argument-hint includes --wave, --gaps-only, and --interactive', () => { const content = fs.readFileSync(COMMAND_PATH, 'utf-8'); - const hintLine = content.split('\n').find(l => l.includes('argument-hint')); + const hintLine = content.split(/\r?\n/).find(l => l.includes('argument-hint')); assert.ok(hintLine, 'should have argument-hint line'); assert.ok(hintLine.includes('--wave N'), 'argument-hint should include --wave N'); assert.ok(hintLine.includes('--gaps-only'), 'argument-hint should keep --gaps-only'); diff --git a/tests/feat-1754-cli-skew-detection.test.cjs b/tests/feat-1754-cli-skew-detection.test.cjs new file mode 100644 index 000000000..7792b518f --- /dev/null +++ b/tests/feat-1754-cli-skew-detection.test.cjs @@ -0,0 +1,85 @@ +'use strict'; + +/** + * feat-1754-cli-skew-detection.test.cjs + * + * Tests for the CLI version-skew detection module (src/cli-skew-check.cts). + * + * The check warns (returns a string) when the running gsd-tools.cjs is NOT the + * project-local install while a project-local install EXISTS — the shadowing + * scenario from #1748 (a stale global canary from @gsd-build/sdk shadowing + * project-local 1.6.0). + * + * DEFECT class: environment / version skew (enhancement #1754) + * + * The function is PURE (no I/O — the caller provides paths + existence flags), + * making it trivially testable without filesystem setup. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); + +const { checkCliSkew } = require('../gsd-core/bin/lib/cli-skew-check.cjs'); + +describe('#1754: checkCliSkew — pure path-comparison skew detection', () => { + test('SKEW: resolved CLI outside project root + project-local exists → returns warning', () => { + const warning = checkCliSkew({ + resolvedPath: '/opt/homebrew/bin/gsd-tools', + projectRoot: '/home/user/my-project', + projectLocalExists: true, + }); + assert.ok(warning, 'Expected a warning string when resolved CLI is outside project root and project-local exists'); + assert.ok(warning.includes('shadow') || warning.includes('outside') || warning.includes('may'), + `Warning should mention the shadowing/outside nature, got: "${warning}"`); + }); + + test('NO-SKEW: resolved CLI is the project-local install → returns null', () => { + const warning = checkCliSkew({ + resolvedPath: '/home/user/my-project/.claude/gsd-core/bin/gsd-tools.cjs', + projectRoot: '/home/user/my-project', + projectLocalExists: true, + }); + assert.strictEqual(warning, null, 'No warning expected when resolved CLI IS the project-local install'); + }); + + test('NO-SKEW: resolved CLI outside project root but NO project-local install → returns null', () => { + const warning = checkCliSkew({ + resolvedPath: '/usr/local/bin/gsd-tools', + projectRoot: '/home/user/my-project', + projectLocalExists: false, + }); + assert.strictEqual(warning, null, 'No warning expected when no project-local install exists (legitimate global-only)'); + }); + + test('NO-SKEW: projectRoot is null (no project context) → returns null', () => { + const warning = checkCliSkew({ + resolvedPath: '/usr/local/bin/gsd-tools', + projectRoot: null, + projectLocalExists: false, + }); + assert.strictEqual(warning, null, 'No warning expected when there is no project root'); + }); + + test('LEGACY-SDK: resolved path contains @gsd-build → warning includes removal instructions', () => { + const warning = checkCliSkew({ + resolvedPath: '/opt/homebrew/lib/node_modules/@gsd-build/sdk/bin/gsd-tools', + projectRoot: '/home/user/my-project', + projectLocalExists: true, + }); + assert.ok(warning, 'Expected a warning for @gsd-build/sdk paths'); + assert.ok(warning.includes('@gsd-build/sdk') || warning.includes('npm uninstall'), + `Warning should include @gsd-build/sdk removal instructions, got: "${warning}"`); + }); + + test('PATH-NORMALIZATION: resolved under project root via realpath → no false positive', () => { + // Even if the resolved path differs in symlink resolution, if it's under the + // project root, it's not a skew. The caller normalizes paths before calling. + const warning = checkCliSkew({ + resolvedPath: path.resolve('/home/user/my-project/.claude/gsd-core/bin/gsd-tools.cjs'), + projectRoot: path.resolve('/home/user/my-project'), + projectLocalExists: true, + }); + assert.strictEqual(warning, null, 'No warning when resolved path is under project root (even with realpath normalization)'); + }); +}); diff --git a/tests/feat-3025-mcp-token-budget-docs.test.cjs b/tests/feat-3025-mcp-token-budget-docs.test.cjs index efd8471fa..a9fe19630 100644 --- a/tests/feat-3025-mcp-token-budget-docs.test.cjs +++ b/tests/feat-3025-mcp-token-budget-docs.test.cjs @@ -40,7 +40,7 @@ const USER_GUIDE_MD = path.join(ROOT, 'docs', 'USER-GUIDE.md'); */ function extractSection(filePath, headerSubstring) { const content = fs.readFileSync(filePath, 'utf8'); - const lines = content.split('\n'); + const lines = content.split(/\r?\n/); let inSection = false; let startDepth = 0; const collected = []; @@ -275,7 +275,7 @@ describe('#3025 markdownlint pre-flight: MD040 + MD056', () => { const section = extractSection(CONTEXT_BUDGET_MD, 'mcp'); // Guard: same null-section concern as MD040 above (CR follow-up). assert.ok(section, 'MCP section not found in context-budget.md — cannot check MD056'); - const lines = section.split('\n'); + const lines = section.split(/\r?\n/); // Walk through and detect tables: header row followed by a separator // (--- pattern) followed by data rows. Count `|` per line. const issues = []; diff --git a/tests/feat-443-effort-install-wiring.install.test.cjs b/tests/feat-443-effort-install-wiring.install.test.cjs index 1dfac84ef..b8b9675d2 100644 --- a/tests/feat-443-effort-install-wiring.install.test.cjs +++ b/tests/feat-443-effort-install-wiring.install.test.cjs @@ -90,10 +90,12 @@ function runGlobalInstall(runtime, tmpHome) { const prev = process.env[envVar]; const prevCwd = process.cwd(); const prevHome = process.env.HOME; + const prevUserProfile = process.env.USERPROFILE; const prevSkipStale = process.env.GSD_SKIP_STALE_SDK_CHECK; process.env[envVar] = tmpHome; process.env.HOME = isolatedHome; + process.env.USERPROFILE = isolatedHome; process.env.GSD_SKIP_STALE_SDK_CHECK = '1'; process.chdir(REPO_ROOT); @@ -105,6 +107,8 @@ function runGlobalInstall(runtime, tmpHome) { else process.env[envVar] = prev; if (prevHome === undefined) delete process.env.HOME; else process.env.HOME = prevHome; + if (prevUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = prevUserProfile; if (prevSkipStale === undefined) delete process.env.GSD_SKIP_STALE_SDK_CHECK; else process.env.GSD_SKIP_STALE_SDK_CHECK = prevSkipStale; // Clean up the isolated HOME dir diff --git a/tests/fix-1445-999x-backlog-excluded-from-total-phases.test.cjs b/tests/fix-1445-999x-backlog-excluded-from-total-phases.test.cjs index 1490db251..00b4d169a 100644 --- a/tests/fix-1445-999x-backlog-excluded-from-total-phases.test.cjs +++ b/tests/fix-1445-999x-backlog-excluded-from-total-phases.test.cjs @@ -16,6 +16,14 @@ * Scenarios: * A. deriveProgressFromRoadmap with a progress table containing a 999.x row. * B. state json total_phases via extractCurrentMilestone / roadmapPhaseCount. + * + * Follow-up #1580: the same `^999` (and Phase 0) sentinel exclusion was missing + * in two more code paths — `milestone complete`'s unstarted-phase guard + * (src/milestone.cts) and `roadmap analyze`'s next_phase routing + phase_count + * (src/roadmap.cts). Scenarios C and D below cover those. + * + * C. milestone complete is NOT blocked by a Phase 999 backlog heading. + * D. roadmap analyze never routes next_phase to 999 / never counts it. */ const { describe, test, beforeEach, afterEach } = require('node:test'); @@ -172,3 +180,116 @@ describe('bug #1445 — state json excludes 999.x phase headings from total_phas ); }); }); + +// ─── Scenario C: milestone complete not blocked by a 999 backlog heading ───── + +describe('fix #1580 — milestone complete ignores the 999 backlog sentinel', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject('fix-1580-mc-'); + const planning = path.join(tmpDir, '.planning'); + // One real, on-disk phase + a directory-less Phase 999 backlog heading. + fs.writeFileSync( + path.join(planning, 'ROADMAP.md'), + [ + '# Roadmap v1.0', + '## v1.0 Milestone', + '## Phases', + '- [x] **Phase 1: Foundation**', + '## Phase Details', + '### Phase 1: Foundation', + '**Goal:** build it', + '### Phase 999: Backlog / Someday', + '**Goal:** deferred, never executed', + ].join('\n'), + 'utf-8', + ); + fs.writeFileSync( + path.join(planning, 'STATE.md'), + `---\nmilestone: v1.0\n---\n# State\n\n**Status:** In progress\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`, + 'utf-8', + ); + const dir = path.join(planning, 'phases', '01-foundation'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'PLAN.md'), '# Plan\n', 'utf-8'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('completes WITHOUT --force despite a Phase 999 backlog heading', () => { + const result = runGsdTools( + ['milestone', 'complete', 'v1.0', '--name', 'Regression'], + tmpDir, + ); + assert.ok( + result.success, + `milestone complete must not be blocked by the 999 sentinel; got error: ${result.error}`, + ); + assert.ok( + !/Cannot mark milestone complete/.test(result.error || ''), + `the unstarted-phase guard must not fire on Phase 999. Got: ${result.error}`, + ); + }); +}); + +// ─── Scenario D: roadmap analyze never routes/ counts the 999 sentinel ──────── + +describe('fix #1580 — roadmap analyze excludes the 999 backlog sentinel', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject('fix-1580-ra-'); + const planning = path.join(tmpDir, '.planning'); + fs.writeFileSync( + path.join(planning, 'ROADMAP.md'), + [ + '# Roadmap v1.0', + '## v1.0 Milestone', + '## Phases', + '- [x] **Phase 1: Foundation**', + '## Phase Details', + '### Phase 1: Foundation', + '**Goal:** build it', + '### Phase 999: Backlog / Someday', + '**Goal:** deferred, never executed', + ].join('\n'), + 'utf-8', + ); + fs.writeFileSync( + path.join(planning, 'STATE.md'), + `---\nmilestone: v1.0\n---\n# State\n`, + 'utf-8', + ); + const dir = path.join(planning, 'phases', '01-foundation'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'PLAN.md'), '# Plan\n', 'utf-8'); + fs.writeFileSync(path.join(dir, 'SUMMARY.md'), '# Summary\n', 'utf-8'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('next_phase is never 999 and phase_count excludes the sentinel', () => { + const result = runGsdTools(['roadmap', 'analyze', '--raw'], tmpDir); + assert.ok(result.success, `roadmap analyze failed: ${result.error}`); + const analysis = JSON.parse(result.output); + assert.notEqual( + String(analysis.next_phase), + '999', + `next_phase must never route to the 999 backlog sentinel. Got ${analysis.next_phase}`, + ); + assert.equal( + analysis.phase_count, + 1, + `phase_count must exclude the 999 sentinel (expected 1). Got ${analysis.phase_count}`, + ); + assert.ok( + !(analysis.phases || []).some(p => String(p.number) === '999'), + 'the phases array must not include the 999 backlog sentinel', + ); + }); +}); diff --git a/tests/fix-1464-docs-manifest-validation.test.cjs b/tests/fix-1464-docs-manifest-validation.test.cjs index 6ff7a1f80..78cd25df9 100644 --- a/tests/fix-1464-docs-manifest-validation.test.cjs +++ b/tests/fix-1464-docs-manifest-validation.test.cjs @@ -32,7 +32,7 @@ const MANIFEST_REQUIRED_KEYS = new Set([ */ function extractManifests(mdContent) { const manifests = []; - const fenceRe = /```json\s*\n([\s\S]*?)```/g; + const fenceRe = /```json\s*\r?\n([\s\S]*?)```/g; let match; while ((match = fenceRe.exec(mdContent)) !== null) { let parsed; diff --git a/tests/fix-1521-non-claude-runtime-default-resolution.test.cjs b/tests/fix-1521-non-claude-runtime-default-resolution.test.cjs index a5297b0b6..d31e94834 100644 --- a/tests/fix-1521-non-claude-runtime-default-resolution.test.cjs +++ b/tests/fix-1521-non-claude-runtime-default-resolution.test.cjs @@ -169,12 +169,14 @@ test('execute-phase.md, quick.md, and diagnose-issues.md guards are generalized }); // --------------------------------------------------------------------------- -// Orchestration gating: manager.md + autonomous.md now gate on codex for -// background dispatch, not on "not claude". (#1521 Stage 2) +// Orchestration gating: manager.md + autonomous.md gate background dispatch on +// the typed FLATTEN query. #1708 (ADR-1239 Phase B) graduated #1521's +// codex-specific check to a documentation-sourced shouldFlattenDispatch — the +// prose now branches on `FLATTEN` (false = background), not a runtime name. // --------------------------------------------------------------------------- -test('manager.md and autonomous.md gate run_in_background on codex specifically (#1521)', () => { - // allow-test-rule: orchestration dispatch gating in manager/autonomous .md is the runtime contract surface (#1521) +test('manager.md and autonomous.md gate run_in_background on FLATTEN=false, not a runtime name (#1521, graduated by #1708)', () => { + // allow-test-rule: orchestration dispatch gating in manager/autonomous .md is the runtime contract surface (#1521/#1708) const manager = fs.readFileSync( path.join(__dirname, '..', 'gsd-core', 'workflows', 'manager.md'), 'utf8', @@ -184,24 +186,29 @@ test('manager.md and autonomous.md gate run_in_background on codex specifically 'utf8', ); - // Both files must gate run_in_background on codex (not on a generic "not claude" condition) + // Both files must gate run_in_background on the typed FLATTEN decision (not a runtime name) assert.ok( - /`RUNTIME` is `codex`[\s\S]{0,500}?run_in_background=true/.test(manager), - 'manager.md: expected run_in_background dispatch gated on RUNTIME=codex specifically', + /If `FLATTEN` is `false`[\s\S]{0,500}?run_in_background=true/.test(manager), + 'manager.md: expected run_in_background dispatch gated on FLATTEN=false (typed dispatch-should-flatten query)', ); assert.ok( - /`RUNTIME` is `codex`[\s\S]{0,700}?run_in_background=true/.test(autonomous), - 'autonomous.md: expected run_in_background dispatch gated on RUNTIME=codex specifically', + /If `FLATTEN` is `false`[\s\S]{0,1200}?run_in_background=true/.test(autonomous), + 'autonomous.md: expected run_in_background dispatch gated on FLATTEN=false (typed dispatch-should-flatten query)', ); - // Inline is the default/else branch (not just claude) + // Inline is the else branch, keyed on FLATTEN — never a runtime name assert.ok( - /Otherwise[\s\S]{0,200}?Claude Code or any other non-Codex runtime/.test(manager), - 'manager.md: expected "Otherwise (Claude Code or any other non-Codex runtime)" inline branch', + /Otherwise[\s\S]{0,250}?inline/i.test(manager), + 'manager.md: expected "Otherwise ... inline" branch keyed on FLATTEN', ); assert.ok( - /Otherwise[\s\S]{0,200}?Claude Code or any other non-Codex runtime/.test(autonomous), - 'autonomous.md: expected "Otherwise (Claude Code or any other non-Codex runtime)" inline branch', + /Otherwise[\s\S]{0,250}?inline/i.test(autonomous), + 'autonomous.md: expected "Otherwise ... inline" branch keyed on FLATTEN', + ); + // And the old runtime-name gating must be gone (no `RUNTIME` is `codex` dispatch gate) + assert.ok( + !/`RUNTIME` is `codex`[\s\S]{0,500}?run_in_background=true/.test(manager), + 'manager.md: must no longer gate run_in_background on the runtime name', ); }); diff --git a/tests/fix-1679-destsubpath-confinement.test.cjs b/tests/fix-1679-destsubpath-confinement.test.cjs new file mode 100644 index 000000000..078428302 --- /dev/null +++ b/tests/fix-1679-destsubpath-confinement.test.cjs @@ -0,0 +1,789 @@ +'use strict'; + +/** + * Tests for ADR-1239 Phase B: destSubpath write-confinement security gate. + * + * Verifies that assertDestWithinConfigHome rejects escaping destSubpath values + * and that createRuntimeArtifactInstallPlan and createRuntimeArtifactUninstallPlan + * both reject them at plan-build time. + * + * Also covers: + * F3 - assertDestWithinConfigHome rejects destSubpath === configHome itself + * F4 - migrateLegacyDevPreferencesToSkill routes through the confinement gate + * F2 - write sites (installOpencodeFamilySkills) reject symlink-escaping destDir + */ + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const { + assertDestWithinConfigHome, + createRuntimeArtifactInstallPlan, + createRuntimeArtifactUninstallPlan, +} = require('../gsd-core/bin/lib/runtime-artifact-install-plan.cjs'); + +const { + migrateLegacyDevPreferencesToSkill, + installOpencodeFamilySkills, + installRuntimeArtifacts, + _copyStaged, +} = require('../gsd-core/bin/lib/install-engine.cjs'); + +const { createTempDir, cleanup } = require('./helpers.cjs'); + +// --------------------------------------------------------------------------- +// Unit tests for assertDestWithinConfigHome +// --------------------------------------------------------------------------- + +describe('assertDestWithinConfigHome', () => { + let configDir; + + beforeEach(() => { + configDir = createTempDir('gsd-confine-test-'); + }); + + afterEach(() => { + cleanup(configDir); + }); + + // --- Rejection cases --- + + test('rejects destSubpath "../../etc" that escapes configDir', () => { + assert.throws( + () => assertDestWithinConfigHome(configDir, '../../etc'), + (err) => { + assert.ok(err instanceof Error, 'must be an Error'); + assert.ok( + err.message.includes('escapes configHome'), + `expected "escapes configHome" in: ${err.message}`, + ); + return true; + }, + ); + }); + + test('rejects destSubpath "../foo" that escapes configDir', () => { + assert.throws( + () => assertDestWithinConfigHome(configDir, '../foo'), + /escapes configHome/, + ); + }); + + test('rejects destSubpath "a/../../b" that escapes configDir', () => { + assert.throws( + () => assertDestWithinConfigHome(configDir, 'a/../../b'), + /escapes configHome/, + ); + }); + + test('rejects destSubpath containing a NUL byte', () => { + assert.throws( + () => assertDestWithinConfigHome(configDir, 'skills\0evil'), + (err) => { + assert.ok(err instanceof Error, 'must be an Error'); + assert.ok( + err.message.includes('NUL'), + `expected "NUL" in: ${err.message}`, + ); + return true; + }, + ); + }); + + // --- F3: reject destSubpath that resolves to configHome itself --- + + test('F3: rejects destSubpath "." that resolves to configHome itself', () => { + assert.throws( + () => assertDestWithinConfigHome(configDir, '.'), + (err) => { + assert.ok(err instanceof Error, 'must be an Error'); + assert.ok( + err.message.includes('not configHome itself') || err.message.includes('escapes configHome'), + `expected confinement error in: ${err.message}`, + ); + return true; + }, + ); + }); + + test('F3: rejects destSubpath "a/.." that resolves to configHome itself', () => { + assert.throws( + () => assertDestWithinConfigHome(configDir, 'a/..'), + (err) => { + assert.ok(err instanceof Error, 'must be an Error'); + assert.ok( + err.message.includes('not configHome itself') || err.message.includes('escapes configHome'), + `expected confinement error in: ${err.message}`, + ); + return true; + }, + ); + }); + + test('F3: rejects destSubpath "skills/../.." that resolves to configHome parent', () => { + assert.throws( + () => assertDestWithinConfigHome(configDir, 'skills/../..'), + (err) => { + assert.ok(err instanceof Error, 'must be an Error'); + assert.ok( + err.message.includes('not configHome itself') || err.message.includes('escapes configHome'), + `expected confinement error in: ${err.message}`, + ); + return true; + }, + ); + }); + + // --- Accepted cases --- + + test('accepts "skills" and returns path under configDir', () => { + const result = assertDestWithinConfigHome(configDir, 'skills'); + assert.ok( + result.startsWith(path.resolve(configDir)), + `expected result to start with configDir (${path.resolve(configDir)}), got: ${result}`, + ); + assert.strictEqual(result, path.join(path.resolve(configDir), 'skills')); + }); + + test('accepts "commands/gsd" and returns path under configDir', () => { + const result = assertDestWithinConfigHome(configDir, 'commands/gsd'); + assert.ok(result.startsWith(path.resolve(configDir))); + assert.strictEqual(result, path.join(path.resolve(configDir), 'commands', 'gsd')); + }); + + test('accepts "./skills" and returns resolved path under configDir', () => { + const result = assertDestWithinConfigHome(configDir, './skills'); + assert.ok(result.startsWith(path.resolve(configDir))); + assert.strictEqual(result, path.join(path.resolve(configDir), 'skills')); + }); + + test('does not match a sibling directory with a shared prefix', () => { + // configDir = /tmp/gsd-foo; a sibling like /tmp/gsd-foobar must NOT be accepted. + // The path.sep guard in the implementation prevents a startsWith match + // from crossing directory boundaries. We verify the happy-path: a valid + // nested subpath resolves to a path strictly under configDir (includes sep). + const result = assertDestWithinConfigHome(configDir, 'subdir/nested'); + assert.ok(result.startsWith(path.resolve(configDir) + path.sep)); + }); +}); + +// --------------------------------------------------------------------------- +// Integration tests for createRuntimeArtifactInstallPlan +// --------------------------------------------------------------------------- + +describe('createRuntimeArtifactInstallPlan destSubpath confinement', () => { + let configDir; + + beforeEach(() => { + configDir = createTempDir('gsd-plan-confine-'); + }); + + afterEach(() => { + cleanup(configDir); + }); + + function noopStage() { + return '/tmp/staged-noop'; + } + + function makeLayout(destSubpath) { + return { + runtime: 'claude', + configDir, + scope: 'global', + kinds: [ + { + kind: 'skills', + destSubpath, + prefix: 'gsd-', + stage: noopStage, + }, + ], + }; + } + + test('rejects an escaping destSubpath ("../../escape") at plan-build time', () => { + const layout = makeLayout('../../escape'); + assert.throws( + () => createRuntimeArtifactInstallPlan({ + layout, + resolvedProfile: { name: 'core' }, + deps: { + rewriteStagedSkillBodies: () => undefined, + rewriteStagedCommandBodies: () => undefined, + }, + }), + (err) => { + assert.ok(err instanceof Error); + assert.ok( + err.message.includes('escapes'), + `expected "escapes" in: ${err.message}`, + ); + return true; + }, + ); + }); + + test('normal destSubpath produces plan with destDir under configDir', () => { + const stagedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-staged-')); + try { + const layout = { + runtime: 'claude', + configDir, + scope: 'global', + kinds: [ + { + kind: 'skills', + destSubpath: 'skills', + prefix: 'gsd-', + stage: () => stagedDir, + }, + ], + }; + + const result = createRuntimeArtifactInstallPlan({ + layout, + resolvedProfile: { name: 'core' }, + deps: { + rewriteStagedSkillBodies: () => undefined, + rewriteStagedCommandBodies: () => undefined, + }, + }); + + assert.strictEqual(result.ok, true, 'plan must succeed for normal destSubpath'); + assert.strictEqual(result.plan.items.length, 1); + const destDir = result.plan.items[0].destDir; + assert.ok( + destDir.startsWith(path.resolve(configDir)), + `destDir (${destDir}) must be under configDir (${configDir})`, + ); + } finally { + cleanup(stagedDir); + } + }); +}); + +// --------------------------------------------------------------------------- +// Integration tests for createRuntimeArtifactUninstallPlan +// --------------------------------------------------------------------------- + +describe('createRuntimeArtifactUninstallPlan destSubpath confinement', () => { + let configDir; + + beforeEach(() => { + configDir = createTempDir('gsd-uninstall-confine-'); + }); + + afterEach(() => { + cleanup(configDir); + }); + + function makeUninstallLayout(destSubpath) { + return { + runtime: 'claude', + configDir, + kinds: [ + { + kind: 'skills', + destSubpath, + prefix: 'gsd-', + stage: () => '/tmp/staged-noop', + }, + ], + }; + } + + test('rejects an escaping destSubpath ("../../escape") at uninstall-plan-build time', () => { + const layout = makeUninstallLayout('../../escape'); + assert.throws( + () => createRuntimeArtifactUninstallPlan(layout), + (err) => { + assert.ok(err instanceof Error); + assert.ok( + err.message.includes('escapes'), + `expected "escapes" in: ${err.message}`, + ); + return true; + }, + ); + }); + + test('rejects destSubpath "../outside" at uninstall-plan-build time', () => { + const layout = makeUninstallLayout('../outside'); + assert.throws( + () => createRuntimeArtifactUninstallPlan(layout), + /escapes/, + ); + }); + + test('normal destSubpath produces uninstall plan with destDir under configDir', () => { + const layout = makeUninstallLayout('skills'); + const plan = createRuntimeArtifactUninstallPlan(layout); + assert.strictEqual(plan.items.length, 1); + const destDir = plan.items[0].destDir; + assert.ok( + destDir.startsWith(path.resolve(configDir)), + `destDir (${destDir}) must be under configDir (${configDir})`, + ); + assert.strictEqual(destDir, path.join(path.resolve(configDir), 'skills')); + }); + + test('normal nested destSubpath ("commands/gsd") produces uninstall plan with destDir under configDir', () => { + const layout = makeUninstallLayout('commands/gsd'); + const plan = createRuntimeArtifactUninstallPlan(layout); + assert.strictEqual(plan.items.length, 1); + const destDir = plan.items[0].destDir; + assert.ok( + destDir.startsWith(path.resolve(configDir)), + `destDir (${destDir}) must be under configDir (${configDir})`, + ); + assert.strictEqual(destDir, path.join(path.resolve(configDir), 'commands', 'gsd')); + }); +}); + +// --------------------------------------------------------------------------- +// F4: migrateLegacyDevPreferencesToSkill must route through the confinement gate +// --------------------------------------------------------------------------- + +describe('F4: migrateLegacyDevPreferencesToSkill confinement', () => { + let configDir; + let outsideDir; + + beforeEach(() => { + configDir = createTempDir('gsd-f4-confine-'); + outsideDir = createTempDir('gsd-f4-outside-'); + }); + + afterEach(() => { + cleanup(configDir); + cleanup(outsideDir); + }); + + test('F4: migrateLegacyDevPreferencesToSkill throws when destSubpath resolves to configHome itself (via mocked layout with "." destSubpath)', () => { + // We cannot easily inject a bad destSubpath through the real layout resolver + // (it resolves to a real valid path). Instead we validate that the function + // uses assertDestWithinConfigHome by passing a runtime whose layout's + // skillsKindEntry.destSubpath, when joined with configDir, would escape — but + // since real layouts are always safe, we test the guard on a deliberately + // crafted saved map calling the real function and observing the path written + // is always within configDir for a real runtime. + // + // Real-layout sanity: verify 'opencode' produces a write inside configDir. + const savedLegacy = new Map([['dev-preferences.md', '# dev prefs\n']]); + // Real opencode layout — should succeed without throwing + assert.doesNotThrow(() => { + migrateLegacyDevPreferencesToSkill(configDir, savedLegacy, 'opencode', 'global'); + }, 'migrateLegacyDevPreferencesToSkill with real opencode layout must not throw'); + + // Verify the written file is inside configDir + const written = []; + function findMd(dir) { + if (!fs.existsSync(dir)) return; + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + if (e.isDirectory()) findMd(path.join(dir, e.name)); + else if (e.name.endsWith('.md')) written.push(path.join(dir, e.name)); + } + } + findMd(configDir); + assert.ok(written.length > 0, 'at least one .md must have been written'); + for (const f of written) { + assert.ok( + f.startsWith(path.resolve(configDir) + path.sep), + `written file ${f} must be inside configDir ${configDir}`, + ); + } + }); + + test('F4: migrateLegacyDevPreferencesToSkill uses assertDestWithinConfigHome — path.join on configDir+destSubpath cannot escape via symlink in destSubpath string', () => { + // Validate that the guard (assertDestWithinConfigHome) would have caught a + // manipulated destSubpath value. We simulate by calling assertDestWithinConfigHome + // directly with a "."-equivalent subpath (F3 guard) to prove F4 now relies on it. + assert.throws( + () => assertDestWithinConfigHome(configDir, '.'), + (err) => { + assert.ok(err instanceof Error); + return true; + }, + 'assertDestWithinConfigHome must reject "." (used by F4 guard)', + ); + }); +}); + +// --------------------------------------------------------------------------- +// F2: write sites reject a symlink-escaping destDir +// --------------------------------------------------------------------------- + +describe('F2: installOpencodeFamilySkills rejects symlink-escaping destDir', () => { + let configDir; + let outsideDir; + let symlinkTarget; + + beforeEach(() => { + configDir = createTempDir('gsd-f2-config-'); + outsideDir = createTempDir('gsd-f2-outside-'); + // Create a symlink inside configDir pointing outside + symlinkTarget = path.join(configDir, 'skills'); + fs.symlinkSync(outsideDir, symlinkTarget); + }); + + afterEach(() => { + // Remove symlink before cleanup to avoid errors + try { fs.unlinkSync(symlinkTarget); } catch { /* already gone */ } + cleanup(configDir); + cleanup(outsideDir); + }); + + test('F2: installOpencodeFamilySkills throws when skills/ is a symlink pointing outside configDir', () => { + // Create a minimal rawCommandsDir with one .md file + const rawDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-f2-raw-')); + try { + fs.writeFileSync(path.join(rawDir, 'help.md'), '# help\n', 'utf8'); + + assert.throws( + () => installOpencodeFamilySkills('opencode', configDir, rawDir, '~/.opencode/'), + (err) => { + assert.ok(err instanceof Error, 'must be an Error'); + assert.ok( + err.message.toLowerCase().includes('symlink') || + err.message.toLowerCase().includes('escap') || + err.message.toLowerCase().includes('outside') || + err.message.toLowerCase().includes('confinement'), + `expected symlink/escape error in: ${err.message}`, + ); + return true; + }, + ); + + // Verify nothing was written to outsideDir + const outsideFiles = fs.readdirSync(outsideDir); + assert.strictEqual(outsideFiles.length, 0, 'must not have written anything outside configDir'); + } finally { + cleanup(rawDir); + } + }); +}); + +// --------------------------------------------------------------------------- +// M1: _copyStaged defense-in-depth must also reject dest === configRoot +// --------------------------------------------------------------------------- + +describe('M1: _copyStaged rejects dest equal to configRoot', () => { + let configDir; + let stagedDir; + + beforeEach(() => { + configDir = createTempDir('gsd-m1-config-'); + stagedDir = createTempDir('gsd-m1-staged-'); + // Write a dummy file into stagedDir so _copyStaged has something to copy + fs.writeFileSync(path.join(stagedDir, 'help.md'), '# help\n', 'utf8'); + }); + + afterEach(() => { + cleanup(configDir); + cleanup(stagedDir); + }); + + test('M1: _copyStaged throws when destDir equals configRoot (was silently accepted before fix)', () => { + // dest === configRoot: the canonical gate (assertDestWithinConfigHome) rejects + // resolved === root with "escapes configHome" / "not configHome itself". + assert.throws( + () => _copyStaged(stagedDir, configDir, { kind: 'commands', destSubpath: '.', prefix: 'gsd-' }, configDir), + (err) => { + assert.ok(err instanceof Error, 'must be an Error'); + assert.ok( + err.message.includes('escapes configHome') || + err.message.includes('not configHome itself') || + err.message.includes('outside') || + err.message.includes('inside'), + `expected confinement error in: ${err.message}`, + ); + return true; + }, + ); + }); + + test('M1: _copyStaged throws when destDir is outside configRoot', () => { + const outsideDir = createTempDir('gsd-m1-outside-'); + try { + assert.throws( + () => _copyStaged(stagedDir, outsideDir, { kind: 'commands', destSubpath: 'commands', prefix: 'gsd-' }, configDir), + (err) => { + assert.ok(err instanceof Error, 'must be an Error'); + assert.ok( + // After EDIT 1, _copyStaged delegates to assertDestWithinConfigHome which + // emits "escapes configHome"; the old "_copyStaged" prefix is no longer present. + err.message.includes('escapes configHome') || + err.message.includes('strict subpath') || + err.message.includes('refusing'), + `expected confinement error in: ${err.message}`, + ); + return true; + }, + ); + } finally { + cleanup(outsideDir); + } + }); + + test('M1: _copyStaged accepts destDir strictly under configRoot', () => { + const destDir = path.join(configDir, 'commands', 'gsd'); + fs.mkdirSync(destDir, { recursive: true }); + // Should not throw — just copies (stagedDir has help.md, kind=commands) + assert.doesNotThrow( + () => _copyStaged(stagedDir, destDir, { kind: 'commands', destSubpath: 'commands/gsd', prefix: 'gsd-' }, configDir), + ); + }); +}); + +// --------------------------------------------------------------------------- +// L2: symlink guard BEFORE mkdirSync in installRuntimeArtifacts +// --------------------------------------------------------------------------- + +describe('L2: installRuntimeArtifacts rejects symlink-escaping dest before mkdirSync', () => { + let configDir; + let outsideDir; + + beforeEach(() => { + configDir = createTempDir('gsd-l2-config-'); + outsideDir = createTempDir('gsd-l2-outside-'); + // Create configDir/skills as a symlink pointing outside + fs.symlinkSync(outsideDir, path.join(configDir, 'skills')); + }); + + afterEach(() => { + // Remove symlink before cleanup to avoid crossing dir boundaries + try { fs.unlinkSync(path.join(configDir, 'skills')); } catch { /* already gone */ } + cleanup(configDir); + cleanup(outsideDir); + }); + + test('L2: installRuntimeArtifacts throws before creating dirs when skills/ is a symlink pointing outside', () => { + // Use the full profile shape (skills: '*') so staging short-circuits early + // and the symlink guard is the first thing that fires. + assert.throws( + () => installRuntimeArtifacts('opencode', configDir, 'global', { name: 'full', skills: '*', agents: new Set() }), + (err) => { + assert.ok(err instanceof Error, 'must be an Error'); + assert.ok( + err.message.toLowerCase().includes('symlink') || + err.message.toLowerCase().includes('escap') || + err.message.toLowerCase().includes('outside') || + err.message.toLowerCase().includes('confinement') || + err.message.toLowerCase().includes('install root'), + `expected symlink/escape error in: ${err.message}`, + ); + return true; + }, + ); + + // The symlink itself still exists but no new entries were created in outsideDir + const outsideEntries = fs.readdirSync(outsideDir); + assert.strictEqual(outsideEntries.length, 0, 'must not have created any dirs/files outside configDir'); + }); +}); + +// --------------------------------------------------------------------------- +// L1: symlink guard in migrateLegacyDevPreferencesToSkill +// --------------------------------------------------------------------------- + +describe('L1: migrateLegacyDevPreferencesToSkill rejects symlink-escaping skillDir', () => { + let configDir; + let outsideDir; + + beforeEach(() => { + configDir = createTempDir('gsd-l1-config-'); + outsideDir = createTempDir('gsd-l1-outside-'); + // Create configDir/skills as a symlink pointing outside + fs.symlinkSync(outsideDir, path.join(configDir, 'skills')); + }); + + afterEach(() => { + try { fs.unlinkSync(path.join(configDir, 'skills')); } catch { /* already gone */ } + cleanup(configDir); + cleanup(outsideDir); + }); + + test('L1: migrateLegacyDevPreferencesToSkill throws when skills/ is a symlink pointing outside', () => { + const saved = new Map([['dev-preferences.md', '# dev prefs\n']]); + assert.throws( + () => migrateLegacyDevPreferencesToSkill(configDir, saved, 'opencode', 'global'), + (err) => { + assert.ok(err instanceof Error, 'must be an Error'); + assert.ok( + err.message.toLowerCase().includes('symlink') || + err.message.toLowerCase().includes('escap') || + err.message.toLowerCase().includes('outside') || + err.message.toLowerCase().includes('install root'), + `expected symlink/escape error in: ${err.message}`, + ); + return true; + }, + ); + + // Nothing must have been written outside + const outsideFiles = fs.readdirSync(outsideDir); + assert.strictEqual(outsideFiles.length, 0, 'must not have written anything outside configDir'); + }); +}); + +// --------------------------------------------------------------------------- +// L3: relative configDir support +// --------------------------------------------------------------------------- + +describe('L3: assertDestWithinConfigHome handles relative configDir', () => { + test('L3: throws when relative configDir + escaping destSubpath resolves outside', () => { + // path.resolve handles relative roots; '../../etc' from '.' would escape + assert.throws( + () => assertDestWithinConfigHome('.', '../../etc'), + (err) => { + assert.ok(err instanceof Error, 'must be an Error'); + assert.ok( + err.message.includes('escapes configHome') || err.message.includes('outside'), + `expected escape error in: ${err.message}`, + ); + return true; + }, + ); + }); + + test('L3: throws when "." destSubpath resolves to the relative configDir itself', () => { + // '.' resolves to the same directory as the configDir — must be rejected (F3) + assert.throws( + () => assertDestWithinConfigHome('.', '.'), + /escapes configHome|not configHome itself/, + ); + }); + + test('L3: accepts "skills" under relative "./somedir" and returns absolute path', () => { + const tmpBase = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-l3-')); + const relDir = path.relative(process.cwd(), tmpBase); + try { + const result = assertDestWithinConfigHome(relDir, 'skills'); + const expectedBase = path.resolve(relDir); + assert.ok( + result.startsWith(expectedBase + path.sep), + `result (${result}) must be under resolved relDir (${expectedBase})`, + ); + assert.strictEqual(result, path.join(expectedBase, 'skills')); + } finally { + fs.rmdirSync(tmpBase); + } + }); +}); + +// --------------------------------------------------------------------------- +// N1: sibling-prefix NEGATIVE assertion +// --------------------------------------------------------------------------- + +describe('N1: sibling directory with shared prefix is rejected', () => { + test('N1: rejects sibling path sharing a prefix with configDir', () => { + // /tmp/gsd-foobar is NOT inside /tmp/gsd-foo — must throw despite the + // startsWith prefix overlap at the string level (the sep-check prevents it). + assert.throws( + () => assertDestWithinConfigHome('/tmp/gsd-foo', '../gsd-foobar'), + (err) => { + assert.ok(err instanceof Error, 'must be an Error'); + assert.ok( + err.message.includes('escapes configHome') || err.message.includes('outside'), + `expected confinement error in: ${err.message}`, + ); + return true; + }, + ); + }); + + test('N1: accepts a true child subpath inside configDir', () => { + // 'bar' appended INSIDE /tmp/gsd-foo => the child path — accepted. + // Compute expected via path.resolve (the same primitive the helper uses) so + // the assertion is platform-portable: on Windows path.resolve prepends the + // cwd drive (C:\...) and uses backslashes, which a hardcoded posix literal / + // path.join (no drive) would not match (#1679 Windows-CI portability). + const root = path.resolve('/tmp/gsd-foo'); + const result = assertDestWithinConfigHome('/tmp/gsd-foo', 'bar'); + assert.strictEqual(result, path.resolve('/tmp/gsd-foo', 'bar')); + assert.ok(result.startsWith(root + path.sep)); + }); + + test('N1: the accepted child does not imply the sibling is accepted', () => { + // Double-check: 'bar' inside is fine, but '../gsd-foobar' (the sibling) is not. + // 'bar' resolves to /tmp/gsd-foo/bar ✓ + assert.doesNotThrow(() => assertDestWithinConfigHome('/tmp/gsd-foo', 'bar')); + // '../gsd-foobar' resolves to /tmp/gsd-foobar — NOT inside /tmp/gsd-foo + assert.throws( + () => assertDestWithinConfigHome('/tmp/gsd-foo', '../gsd-foobar'), + /escapes configHome/, + ); + }); +}); + +// --------------------------------------------------------------------------- +// N3: Windows-separator coverage (structural guard using path.win32) +// --------------------------------------------------------------------------- + +describe('N3: Windows-separator confinement logic (path.win32 semantics)', () => { + /** + * Replicate the assertDestWithinConfigHome predicate using path.win32 + * so we can test the sep-guard logic on any platform. + * + * This mirrors the implementation in runtime-artifact-install-plan.cjs + * but forces win32 path semantics. + */ + function assertDestWithinConfigHomeWin32(configDir, destSubpath) { + if (destSubpath.includes('\0')) { + throw new Error(`destSubpath "${destSubpath}" contains a NUL byte and is not valid`); + } + const root = path.win32.resolve(configDir); + const resolved = path.win32.resolve(configDir, destSubpath); + if (resolved === root || !resolved.startsWith(root + path.win32.sep)) { + throw new Error( + `destSubpath "${destSubpath}" must be a strict subpath of configHome "${configDir}" — not configHome itself or outside it (escapes configHome)`, + ); + } + return resolved; + } + + const winRoot = 'C:\\Users\\me\\.claude'; + + test('N3: rejects ..\\..\\Windows (Windows backslash traversal)', () => { + assert.throws( + () => assertDestWithinConfigHomeWin32(winRoot, '..\\..\\Windows'), + /escapes configHome/, + ); + }); + + test('N3: rejects mixed ../..\\x traversal', () => { + assert.throws( + () => assertDestWithinConfigHomeWin32(winRoot, '../..\\x'), + /escapes configHome/, + ); + }); + + test('N3: rejects "." that resolves to configHome itself', () => { + assert.throws( + () => assertDestWithinConfigHomeWin32(winRoot, '.'), + /escapes configHome/, + ); + }); + + test('N3: accepts "skills" under Windows root', () => { + const result = assertDestWithinConfigHomeWin32(winRoot, 'skills'); + assert.strictEqual(result, path.win32.join(winRoot, 'skills')); + assert.ok(result.startsWith(winRoot + path.win32.sep)); + }); + + test('N3: accepts "commands\\gsd" (Windows nested path) under Windows root', () => { + const result = assertDestWithinConfigHomeWin32(winRoot, 'commands\\gsd'); + assert.strictEqual(result, path.win32.join(winRoot, 'commands', 'gsd')); + assert.ok(result.startsWith(winRoot + path.win32.sep)); + }); + + test('N3: rejects sibling C:\\Users\\me\\.claude-extra under win32 semantics', () => { + assert.throws( + () => assertDestWithinConfigHomeWin32(winRoot, '..\\.claude-extra'), + /escapes configHome/, + ); + }); +}); diff --git a/tests/fixtures/golden-install-parity/antigravity.json b/tests/fixtures/golden-install-parity/antigravity.json new file mode 100644 index 000000000..2c123d712 --- /dev/null +++ b/tests/fixtures/golden-install-parity/antigravity.json @@ -0,0 +1,403 @@ +{ + ".gsd-profile": "0e716a5fef4e6dc1", + ".gsd/defaults.json": "560664b045e645cb", + "agents/gsd-advisor-researcher.md": "a281144575a6dc09", + "agents/gsd-ai-researcher.md": "bb91690281b7ea44", + "agents/gsd-assumptions-analyzer.md": "e2e0ad497cedd460", + "agents/gsd-code-fixer.md": "5b688699cecbb3a8", + "agents/gsd-code-reviewer.md": "0d4c658a2fec15a7", + "agents/gsd-codebase-mapper.md": "e26b6aeb89836631", + "agents/gsd-debug-session-manager.md": "88a3726efbc28eed", + "agents/gsd-debugger.md": "ba550c0c2f51679e", + "agents/gsd-doc-classifier.md": "6dde566846b268b0", + "agents/gsd-doc-synthesizer.md": "6286afdbd9a74fe0", + "agents/gsd-doc-verifier.md": "888c6ac870cb91a6", + "agents/gsd-doc-writer.md": "f1581580739a7a36", + "agents/gsd-domain-researcher.md": "1db46cac3f4d9889", + "agents/gsd-eval-auditor.md": "3b89138f0f573739", + "agents/gsd-eval-planner.md": "3d10fd11147f6857", + "agents/gsd-executor.md": "2ddfb93dd7b80f20", + "agents/gsd-framework-selector.md": "daa62c79619c76bf", + "agents/gsd-integration-checker.md": "e3f0c93ce7e93f36", + "agents/gsd-intel-updater.md": "c2a053ffbe79cc74", + "agents/gsd-mempalace-curator.md": "a7269018acb973ce", + "agents/gsd-nyquist-auditor.md": "bac98abeecf6530f", + "agents/gsd-pattern-mapper.md": "e62ee90d39084802", + "agents/gsd-phase-researcher.md": "bbe33441c3979e75", + "agents/gsd-plan-checker.md": "20c36e148de1ba7a", + "agents/gsd-planner.md": "2c2b8eb93d8e03c1", + "agents/gsd-project-researcher.md": "b53b3e44fafe4cb0", + "agents/gsd-research-synthesizer.md": "f9cc330e679d5def", + "agents/gsd-roadmapper.md": "8c3748caf7cd57d7", + "agents/gsd-security-auditor.md": "c4913863961b0dcf", + "agents/gsd-ui-auditor.md": "f09aef81bfcfd412", + "agents/gsd-ui-checker.md": "dbbe694a26265473", + "agents/gsd-ui-researcher.md": "8c7e91c85e7099f5", + "agents/gsd-user-profiler.md": "25d65f6458454764", + "agents/gsd-verifier.md": "2a64590bb09e21e6", + "gsd-core/CHANGELOG.md": "e141e3fb369ff712", + "gsd-core/VERSION": "562368b20a64be95", + "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", + "gsd-core/bin/gsd-tools.cjs": "46deb2174be356dd", + "gsd-core/bin/gsd_run": "62d9b647ede212e6", + "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", + "gsd-core/bin/shared/config-schema.manifest.json": "67e4addbfd248a7c", + "gsd-core/bin/shared/model-catalog.json": "dbe26e683236d8c6", + "gsd-core/bin/shared/runtime-aliases.manifest.json": "f6c8b3af10dcfdc4", + "gsd-core/bin/verify-reapply-patches.cjs": "8bc541aabc2e143c", + "gsd-core/contexts/dev.md": "dcb0de9dce33cf41", + "gsd-core/contexts/research.md": "b3285d8e7209cc3b", + "gsd-core/contexts/review.md": "dc578fdd74bbea11", + "gsd-core/references/agent-contracts.md": "ff65e633c656c0d2", + "gsd-core/references/ai-evals.md": "b5afa786b938671e", + "gsd-core/references/ai-frameworks.md": "425dd69c629230e7", + "gsd-core/references/artifact-types.md": "e176817364a7cbf4", + "gsd-core/references/autonomous-smart-discuss.md": "efd80aca449032ad", + "gsd-core/references/checkpoints.md": "2de680837faa9752", + "gsd-core/references/common-bug-patterns.md": "780145be56352626", + "gsd-core/references/context-budget.md": "177520ead2ae3a23", + "gsd-core/references/continuation-format.md": "580287399ad3ba68", + "gsd-core/references/debugger-philosophy.md": "1cb61d4565081c88", + "gsd-core/references/decimal-phase-calculation.md": "46b5ba045852c474", + "gsd-core/references/doc-conflict-engine.md": "883d0a1b9d9ff96e", + "gsd-core/references/domain-probes.md": "762b965e84035b72", + "gsd-core/references/edge-probe-fixtures/01-round-half-even/expected-coverage.json": "72d1e29cedc854ec", + "gsd-core/references/edge-probe-fixtures/01-round-half-even/requirements.json": "fbc1b355d8625eeb", + "gsd-core/references/edge-probe-fixtures/02-merge-intervals/expected-coverage.json": "fad67dcc8294f6da", + "gsd-core/references/edge-probe-fixtures/02-merge-intervals/requirements.json": "30a78ee9ce3473ea", + "gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/expected-coverage.json": "66dd60957fee45f0", + "gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/requirements.json": "47fca61f076835fa", + "gsd-core/references/edge-probe-fixtures/04-money-rounding/expected-coverage.json": "72d1e29cedc854ec", + "gsd-core/references/edge-probe-fixtures/04-money-rounding/requirements.json": "80f04f5c04fb24cf", + "gsd-core/references/edge-probe-fixtures/05-list-dedupe/expected-coverage.json": "fad67dcc8294f6da", + "gsd-core/references/edge-probe-fixtures/05-list-dedupe/requirements.json": "d38147adb0e5b342", + "gsd-core/references/edge-probe-fixtures/06-resolved-mixed/expected-coverage.json": "bc552c01939bf4f8", + "gsd-core/references/edge-probe-fixtures/06-resolved-mixed/requirements.json": "30a78ee9ce3473ea", + "gsd-core/references/edge-probe-fixtures/06-resolved-mixed/resolutions.json": "688ec62c13e08afe", + "gsd-core/references/edge-probe.md": "89c8d04ce0d31d0d", + "gsd-core/references/execute-mvp-tdd.md": "a98a270a7ab126bc", + "gsd-core/references/execute-phase-between-wave-reset.md": "3ad96ca0f7fee37e", + "gsd-core/references/execute-phase-context-guard.md": "a5a1058d35806a8e", + "gsd-core/references/execute-phase-wave-guard.md": "826cf9315e592a1d", + "gsd-core/references/executor-examples.md": "ba59243ed45c8ab1", + "gsd-core/references/few-shot-examples/plan-checker.md": "2574808188ac9de4", + "gsd-core/references/few-shot-examples/verifier.md": "5badee4560b14ae8", + "gsd-core/references/gate-prompts.md": "43e867720e736464", + "gsd-core/references/gates.md": "7dc9fd3a3d6217c6", + "gsd-core/references/git-integration.md": "9e6076a137f9e156", + "gsd-core/references/git-planning-commit.md": "f897a15ebfc3f5a7", + "gsd-core/references/ios-scaffold.md": "5ef0cb7e0fac891f", + "gsd-core/references/loop-hook-dispatch.md": "32e5dfb4dba76987", + "gsd-core/references/mandatory-initial-read.md": "fe59abce693717cf", + "gsd-core/references/model-profile-resolution.md": "de81380316d8a37f", + "gsd-core/references/model-profiles.md": "9e160d4e754b6b9e", + "gsd-core/references/mvp-concepts.md": "3464783eaaef5c10", + "gsd-core/references/phase-argument-parsing.md": "e5bbb985f3bc3e34", + "gsd-core/references/planner-antipatterns.md": "2459c40bfc8a0ea8", + "gsd-core/references/planner-chunked.md": "79fe674221e738e6", + "gsd-core/references/planner-gap-closure.md": "76bee257911413e7", + "gsd-core/references/planner-graphify-auto-update.md": "1ed614dfba72f2a3", + "gsd-core/references/planner-guidance.md": "a6a2435b0e5b2871", + "gsd-core/references/planner-human-verify-mode.md": "676e43b03af6b25b", + "gsd-core/references/planner-interface-context.md": "b28fa3da6ae739a8", + "gsd-core/references/planner-load-graph-context.md": "90cb2ecd1f3eb4d8", + "gsd-core/references/planner-mvp-mode.md": "343b859a60bcd15e", + "gsd-core/references/planner-reviews.md": "da39eace09a10743", + "gsd-core/references/planner-revision.md": "86ba8a511f081f05", + "gsd-core/references/planner-source-audit.md": "7de5bdb07232ce0b", + "gsd-core/references/planning-config.md": "8ce19741d18507f7", + "gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json": "f10df472f2846cc6", + "gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json": "31e8a781eeffe020", + "gsd-core/references/prohibition-probe-fixtures/03-multi-prohibition/expected.json": "70a532a7cc1b6ae8", + "gsd-core/references/prohibition-probe.md": "e1cf3b27e936e07d", + "gsd-core/references/project-skills-discovery.md": "d8701bc00a470923", + "gsd-core/references/questioning.md": "8f26dfe5794b1e6e", + "gsd-core/references/research-documentation-lookup.md": "c070007d1d72ab71", + "gsd-core/references/research-philosophy.md": "a602b41bfa081fdf", + "gsd-core/references/research-verification-protocol.md": "9c38c9d9a687e679", + "gsd-core/references/revision-loop.md": "e55ff32dd98c63df", + "gsd-core/references/scout-codebase.md": "ba266ecc18fbf172", + "gsd-core/references/security-asvs-levels.md": "4774fac3b94b6ca8", + "gsd-core/references/skeleton-template.md": "528691d1f0efa878", + "gsd-core/references/sketch-interactivity.md": "7d982fe877e1e1cc", + "gsd-core/references/sketch-theme-system.md": "33e2e96e450456f8", + "gsd-core/references/sketch-tooling.md": "df6c4f24c1c27611", + "gsd-core/references/sketch-variant-patterns.md": "66c197aa4fb52810", + "gsd-core/references/spidr-splitting.md": "074ac154c0e4f906", + "gsd-core/references/tdd.md": "e4708ede157478b6", + "gsd-core/references/thinking-models-debug.md": "2da61022b16c4e7c", + "gsd-core/references/thinking-models-execution.md": "dcc650a8b5f3e049", + "gsd-core/references/thinking-models-planning.md": "7e19462313fa028f", + "gsd-core/references/thinking-models-research.md": "5f6bf3f3b889c6e4", + "gsd-core/references/thinking-models-verification.md": "a71a933d51ca3d8d", + "gsd-core/references/thinking-partner.md": "827c1badf3e6df41", + "gsd-core/references/ui-brand.md": "48717bcfcd63bd27", + "gsd-core/references/universal-anti-patterns.md": "6a1245050b21df01", + "gsd-core/references/untrusted-input-boundary.md": "d33b80d4d348599a", + "gsd-core/references/user-profiling.md": "8e023a908d968af1", + "gsd-core/references/user-story-template.md": "0cc50e06a144ff8a", + "gsd-core/references/verification-overrides.md": "a3e2d5166d16a37b", + "gsd-core/references/verification-patterns.md": "de7ebb43dea1d20f", + "gsd-core/references/verify-mvp-mode.md": "534bdc7f2432903a", + "gsd-core/references/workstream-flag.md": "225910640adbb76d", + "gsd-core/references/worktree-branch-check.md": "21d9c31bf6542b93", + "gsd-core/references/worktree-path-safety.md": "3c8d74756f9b16a8", + "gsd-core/templates/AI-SPEC.md": "24df5fe5ba34e367", + "gsd-core/templates/DEBUG.md": "a2eea97e7f1336bf", + "gsd-core/templates/README.md": "28160dd8b0631652", + "gsd-core/templates/SECURITY.md": "b628f7f1c6d2328f", + "gsd-core/templates/UAT.md": "68d32d1fea14e184", + "gsd-core/templates/UI-SPEC.md": "20ca56a4e3e21f01", + "gsd-core/templates/VALIDATION.md": "6144951011cdca57", + "gsd-core/templates/claude-md.md": "8797c0c7da927c8e", + "gsd-core/templates/codebase/architecture.md": "282db635ba093b1a", + "gsd-core/templates/codebase/concerns.md": "e66c584daa636fc5", + "gsd-core/templates/codebase/conventions.md": "c5f0324730852701", + "gsd-core/templates/codebase/integrations.md": "39bd23c71eedd564", + "gsd-core/templates/codebase/stack.md": "116e7e67dd87ddec", + "gsd-core/templates/codebase/structure.md": "6c505bdf5af338c8", + "gsd-core/templates/codebase/testing.md": "76abff7f2050c9ea", + "gsd-core/templates/config.json": "1473dff11272d3c4", + "gsd-core/templates/context.md": "593d4e6d20fdde7c", + "gsd-core/templates/continue-here.md": "8b1dce4832c7922b", + "gsd-core/templates/copilot-instructions.md": "aea34bc52ff548ea", + "gsd-core/templates/debug-subagent-prompt.md": "8c18a89e25929d8e", + "gsd-core/templates/dev-preferences.md": "95048a71063d980b", + "gsd-core/templates/discovery.md": "e4ab738326eb70e0", + "gsd-core/templates/discussion-log.md": "da4e8e266efdc6c2", + "gsd-core/templates/milestone-archive.md": "591b6decdc0c0e51", + "gsd-core/templates/milestone.md": "74d2f750ae9f4a9c", + "gsd-core/templates/phase-prompt.md": "31f627d456ab14b0", + "gsd-core/templates/planner-subagent-prompt.md": "6c9f1b23ee3dc05f", + "gsd-core/templates/project.md": "e4a3b331be081e62", + "gsd-core/templates/requirements.md": "a44de4c2f146e473", + "gsd-core/templates/research-project/ARCHITECTURE.md": "746b9ef791d758b0", + "gsd-core/templates/research-project/FEATURES.md": "f2b800de5df91b0f", + "gsd-core/templates/research-project/PITFALLS.md": "3ef75fa768422eec", + "gsd-core/templates/research-project/STACK.md": "82c85799ac4dd344", + "gsd-core/templates/research-project/SUMMARY.md": "dceb2f346388839d", + "gsd-core/templates/research.md": "b454f96fdf8b1d3d", + "gsd-core/templates/retrospective.md": "03981e30dd760103", + "gsd-core/templates/roadmap.md": "e4e35a9eb5dd4d4f", + "gsd-core/templates/spec.md": "8734f0df4df3a34b", + "gsd-core/templates/state.md": "a45a134631efe3f9", + "gsd-core/templates/summary-complex.md": "a5e40574fd8894dc", + "gsd-core/templates/summary-minimal.md": "7d09b5e709e2e67c", + "gsd-core/templates/summary-standard.md": "e8d9cf4a8377cdff", + "gsd-core/templates/summary.md": "23c40f6503b3ea98", + "gsd-core/templates/user-profile.md": "52abe2af968e8533", + "gsd-core/templates/user-setup.md": "1da2382725db080f", + "gsd-core/templates/verification-report.md": "78ab9264ce63ed7e", + "gsd-core/workflows/_runtime-launcher.snippet.sh": "c0994e00f976dbfb", + "gsd-core/workflows/add-backlog.md": "a2d025013d8ef7fd", + "gsd-core/workflows/add-phase.md": "b619b3402c1e95db", + "gsd-core/workflows/add-tests.md": "206ccd9c8f25fac9", + "gsd-core/workflows/add-todo.md": "a995f9f4b11fcf23", + "gsd-core/workflows/ai-integration-phase.md": "f2fefee40ec7413c", + "gsd-core/workflows/analyze-dependencies.md": "77aff48f97fa6f1c", + "gsd-core/workflows/audit-fix.md": "9787a0ef00be649c", + "gsd-core/workflows/audit-milestone.md": "5e73ad8112a9b9b4", + "gsd-core/workflows/audit-uat.md": "777262a63dcaacdc", + "gsd-core/workflows/autonomous.md": "f145bbc649fd8aa6", + "gsd-core/workflows/check-todos.md": "f6a93906922dd939", + "gsd-core/workflows/cleanup.md": "53aa20672fe253bd", + "gsd-core/workflows/code-review-fix.md": "715d98a6ff5931e2", + "gsd-core/workflows/code-review.md": "d3d22e8705ab8323", + "gsd-core/workflows/complete-milestone.md": "74ca982730c7d735", + "gsd-core/workflows/debug.md": "5ec71cc100973523", + "gsd-core/workflows/diagnose-issues.md": "4d80e23ab8adf2d7", + "gsd-core/workflows/discovery-phase.md": "3de990caffdde4f8", + "gsd-core/workflows/discuss-phase-assumptions.md": "5758ef496180a20c", + "gsd-core/workflows/discuss-phase-power.md": "5c0d1ca6abda0383", + "gsd-core/workflows/discuss-phase.md": "19659f106fa0f53e", + "gsd-core/workflows/discuss-phase/modes/advisor.md": "47ec4454046099c6", + "gsd-core/workflows/discuss-phase/modes/all.md": "e5fe9c9c1e2bec1a", + "gsd-core/workflows/discuss-phase/modes/analyze.md": "da0788f3be7f8105", + "gsd-core/workflows/discuss-phase/modes/auto.md": "5256d93dca08278e", + "gsd-core/workflows/discuss-phase/modes/batch.md": "6946597770e2d448", + "gsd-core/workflows/discuss-phase/modes/chain.md": "3a90a0c95016f7c5", + "gsd-core/workflows/discuss-phase/modes/default.md": "4c5ba5975dcc1e9c", + "gsd-core/workflows/discuss-phase/modes/power.md": "96822b22c42d75e8", + "gsd-core/workflows/discuss-phase/modes/text.md": "c384c22ffff4dc02", + "gsd-core/workflows/discuss-phase/templates/checkpoint.json": "e3bc3dca49db59eb", + "gsd-core/workflows/discuss-phase/templates/context.md": "9804674fccada405", + "gsd-core/workflows/discuss-phase/templates/discussion-log.md": "2b56ec2868cbddb4", + "gsd-core/workflows/do.md": "ecf6a2ae3c27c2af", + "gsd-core/workflows/docs-update.md": "1c3a0d23ecc9605c", + "gsd-core/workflows/edit-phase.md": "f685fb7063616826", + "gsd-core/workflows/eval-review.md": "2da6215ce79b2f7c", + "gsd-core/workflows/execute-phase.md": "aae2c741af3e6526", + "gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md": "1da22ba32f524c6e", + "gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md": "7ebb7d1af6082028", + "gsd-core/workflows/execute-phase/steps/post-merge-gate.md": "bb9113e7bf953797", + "gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md": "be84efbd71e1513e", + "gsd-core/workflows/execute-plan.md": "7a8c667c3587a985", + "gsd-core/workflows/explore.md": "d9593c9f00250e53", + "gsd-core/workflows/extract-learnings.md": "caa2ff0160b6ed9c", + "gsd-core/workflows/fast.md": "54fe93778b45a7eb", + "gsd-core/workflows/forensics.md": "c32971f6b9d61ed8", + "gsd-core/workflows/graduation.md": "b401418dfea2b0f5", + "gsd-core/workflows/health.md": "3ab04acb14942ddd", + "gsd-core/workflows/help.md": "5d040504b9ab35e3", + "gsd-core/workflows/help/modes/brief.md": "2e923b3ed635d495", + "gsd-core/workflows/help/modes/default.md": "b544113eb9aa54fa", + "gsd-core/workflows/help/modes/full.md": "200a42681ef05a29", + "gsd-core/workflows/help/modes/topic.md": "a404990fcf2d7df7", + "gsd-core/workflows/import.md": "3cbe85643922fd3b", + "gsd-core/workflows/inbox.md": "a448220c548f27bc", + "gsd-core/workflows/ingest-docs.md": "783e0380797478cc", + "gsd-core/workflows/insert-phase.md": "d48c4aa1e864c852", + "gsd-core/workflows/list-phase-assumptions.md": "e7b5e35c9452e3b3", + "gsd-core/workflows/list-seeds.md": "084c87cd310119b2", + "gsd-core/workflows/list-workspaces.md": "69aa6dcd8f63bb8a", + "gsd-core/workflows/manager.md": "187e2df237bcf418", + "gsd-core/workflows/map-codebase.md": "ea0ad99dd10c3d1c", + "gsd-core/workflows/milestone-summary.md": "6af78ebf0ba7546b", + "gsd-core/workflows/mvp-phase.md": "cd39027462579233", + "gsd-core/workflows/new-milestone.md": "5abba9fb6c9c3252", + "gsd-core/workflows/new-project.md": "b8dc0226e885ea9c", + "gsd-core/workflows/new-workspace.md": "70dec4f25df02d64", + "gsd-core/workflows/next.md": "c45606fc89965aed", + "gsd-core/workflows/node-repair.md": "07a1628e5a1ff96b", + "gsd-core/workflows/note.md": "3ce09c0aa0a20599", + "gsd-core/workflows/pause-work.md": "3196d681d4dd8c71", + "gsd-core/workflows/plan-milestone-gaps.md": "94b193dfc9ca3681", + "gsd-core/workflows/plan-phase.md": "d99fb8159a2db1a9", + "gsd-core/workflows/plan-review-convergence.md": "b1623082557cdca5", + "gsd-core/workflows/plant-seed.md": "1fb45cd49f66f572", + "gsd-core/workflows/pr-branch.md": "c8fd9fa250cb39fd", + "gsd-core/workflows/profile-user.md": "7b894598e26133b2", + "gsd-core/workflows/progress.md": "6087275a7ef3d84f", + "gsd-core/workflows/quick.md": "99feafd49e32e387", + "gsd-core/workflows/reapply-patches.md": "825e37a55a992892", + "gsd-core/workflows/remove-phase.md": "6b9947fba1a97f46", + "gsd-core/workflows/remove-workspace.md": "95f05defffe6754e", + "gsd-core/workflows/resume-project.md": "cadf390bae95fc76", + "gsd-core/workflows/review.md": "439d0088fbc29350", + "gsd-core/workflows/scan.md": "f2754f3e3ea528ff", + "gsd-core/workflows/secure-phase.md": "78705b7f09612464", + "gsd-core/workflows/session-report.md": "2e5b1205324ddefa", + "gsd-core/workflows/settings-advanced.md": "e48b1dd7b6905572", + "gsd-core/workflows/settings-integrations.md": "83360968c4436bd1", + "gsd-core/workflows/settings.md": "a107a377ddeffed7", + "gsd-core/workflows/ship.md": "438fbd3ec509c1f1", + "gsd-core/workflows/sketch-wrap-up.md": "ab9fbb7371ef36bf", + "gsd-core/workflows/sketch.md": "114ca2455c2eb6fc", + "gsd-core/workflows/spec-phase.md": "5d6f0c480c0b251d", + "gsd-core/workflows/spike-wrap-up.md": "e2f251b7c8bfa2b2", + "gsd-core/workflows/spike.md": "0f9a81bcf4573195", + "gsd-core/workflows/stats.md": "a20eb078d2ab11be", + "gsd-core/workflows/sync-skills.md": "8326a7ff0411b077", + "gsd-core/workflows/thread.md": "03a527a71b8fab12", + "gsd-core/workflows/transition.md": "a7a5fe4040084308", + "gsd-core/workflows/ui-phase.md": "652785fbba26e80c", + "gsd-core/workflows/ui-review.md": "816b2bde136157f9", + "gsd-core/workflows/ultraplan-phase.md": "2404a01cb2e0c450", + "gsd-core/workflows/undo.md": "6ab639d1fc7e0721", + "gsd-core/workflows/update.md": "dc93f366e2156e37", + "gsd-core/workflows/validate-phase.md": "5bac28c71d21c740", + "gsd-core/workflows/verify-phase.md": "1c6a2e1128966675", + "gsd-core/workflows/verify-work.md": "dd7f78f947b86976", + "hooks/gsd-check-update-worker.js": "668c24ea284ff623", + "hooks/gsd-check-update.js": "7e42f76b2bcdd764", + "hooks/gsd-config-reload.js": "17bf778d432b3d2a", + "hooks/gsd-context-monitor.js": "ead852d2b4ddb92a", + "hooks/gsd-cursor-post-tool.js": "d61ee04f6ee7858c", + "hooks/gsd-cursor-session-start.js": "148b8ec4e2c97f00", + "hooks/gsd-ensure-canonical-path.js": "83e02e841e123037", + "hooks/gsd-graphify-update.sh": "396ebda3c6705dc9", + "hooks/gsd-phase-boundary.sh": "6aa3ba9af3d465d9", + "hooks/gsd-prompt-guard.js": "4b08c2dce0233e2d", + "hooks/gsd-read-guard.js": "b602f88f046a7551", + "hooks/gsd-read-injection-scanner.js": "d17d30e2b1a42582", + "hooks/gsd-session-state.sh": "b1496e6a5204a6df", + "hooks/gsd-statusline.js": "c3ceac8122b2c3ed", + "hooks/gsd-update-banner.js": "74817c820b7a4ec1", + "hooks/gsd-validate-commit.sh": "14d3d966c74dc310", + "hooks/gsd-workflow-guard.js": "e22b9fb57f0e64f6", + "hooks/gsd-worktree-path-guard.js": "25969c741edaf032", + "hooks/lib/git-cmd.js": "268ba15992ca0b23", + "hooks/lib/gsd-graphify-rebuild.sh": "66af89601074d2a9", + "hooks/managed-hooks-registry.cjs": "45b2431992d3d7d2", + "package.json": "dbf8353f77358bc1", + "scripts/changeset/README.md": "86ff89331dfd94b2", + "scripts/changeset/cli.cjs": "68f92a344b199271", + "scripts/changeset/github-release-notes.cjs": "795677f0c009b132", + "scripts/changeset/lint.cjs": "0066faed159154f0", + "scripts/changeset/new.cjs": "4991e21fd17f5541", + "scripts/changeset/parse.cjs": "f9a949cbcab56445", + "scripts/changeset/render.cjs": "e47bc3e1587c3cae", + "scripts/changeset/serialize.cjs": "ac0b8fe6f87cdb0e", + "scripts/fix-slash-commands.cjs": "0519742531ff3529", + "scripts/lib/allowlist-ratchet.cjs": "ffaceaac3efc2660", + "scripts/lib/cli-exit.cjs": "612d0c372c75b7e7", + "skills/gsd-add-tests/SKILL.md": "95cd3eb1698fc79b", + "skills/gsd-ai-integration-phase/SKILL.md": "0dc87ce16bc31884", + "skills/gsd-audit-fix/SKILL.md": "e5e30c307bca6c89", + "skills/gsd-audit-milestone/SKILL.md": "f701c229ac380077", + "skills/gsd-audit-uat/SKILL.md": "8b304b54e1c587d1", + "skills/gsd-autonomous/SKILL.md": "5610d6fceff3774e", + "skills/gsd-capture/SKILL.md": "c554bbadc3885b8f", + "skills/gsd-cleanup/SKILL.md": "c3ed4f3abf6945b6", + "skills/gsd-code-review/SKILL.md": "9aa941f7cfa5f185", + "skills/gsd-complete-milestone/SKILL.md": "169568f9004be781", + "skills/gsd-config/SKILL.md": "4b099e45ec600133", + "skills/gsd-debug/SKILL.md": "168a98458c3b41db", + "skills/gsd-discuss-phase/SKILL.md": "3fb922bfac348f8d", + "skills/gsd-docs-update/SKILL.md": "b50b433dfe6208ef", + "skills/gsd-eval-review/SKILL.md": "32bb1f952e4b905e", + "skills/gsd-execute-phase/SKILL.md": "eddbad8efb90ab66", + "skills/gsd-explore/SKILL.md": "8e3a822f314389af", + "skills/gsd-extract-learnings/SKILL.md": "1d913b8cecc42506", + "skills/gsd-fast/SKILL.md": "d62c1699462f6e74", + "skills/gsd-forensics/SKILL.md": "352bd1d8b4c26c3b", + "skills/gsd-graphify/SKILL.md": "5b812b9ed52f8d9a", + "skills/gsd-health/SKILL.md": "9acfbd91df231ada", + "skills/gsd-help/SKILL.md": "75a119a393b6c65b", + "skills/gsd-import/SKILL.md": "72f38b97d8397dba", + "skills/gsd-inbox/SKILL.md": "4ec0198966ac4f7d", + "skills/gsd-ingest-docs/SKILL.md": "f4e43968af317ed8", + "skills/gsd-manager/SKILL.md": "6e51785860784d8c", + "skills/gsd-map-codebase/SKILL.md": "e10431a9386c6aec", + "skills/gsd-mempalace-capture/SKILL.md": "5647f915e5c85482", + "skills/gsd-mempalace-recall/SKILL.md": "d41523e659d1920f", + "skills/gsd-milestone-summary/SKILL.md": "2955ab3e3a444ca3", + "skills/gsd-mvp-phase/SKILL.md": "1cf7622d655840ac", + "skills/gsd-new-milestone/SKILL.md": "f97a28dd1e2b9317", + "skills/gsd-new-project/SKILL.md": "31b46764278d1c1a", + "skills/gsd-ns-context/SKILL.md": "e791bc60e4cf124e", + "skills/gsd-ns-ideate/SKILL.md": "b85f270e8415f595", + "skills/gsd-ns-manage/SKILL.md": "d55c53b5b0513033", + "skills/gsd-ns-project/SKILL.md": "64844073a6115c45", + "skills/gsd-ns-review/SKILL.md": "2e30158e112d173c", + "skills/gsd-ns-workflow/SKILL.md": "79373d30cd17ed49", + "skills/gsd-pause-work/SKILL.md": "9d3cc6bd70b03df1", + "skills/gsd-phase/SKILL.md": "00676bbea61410bf", + "skills/gsd-plan-phase/SKILL.md": "168046ccf9702532", + "skills/gsd-plan-review-convergence/SKILL.md": "af6ed50b242c64b7", + "skills/gsd-pr-branch/SKILL.md": "5e050db73988f9a8", + "skills/gsd-profile-user/SKILL.md": "10f2ff4be2e7d55f", + "skills/gsd-progress/SKILL.md": "df0d06cf9d5963d0", + "skills/gsd-quick/SKILL.md": "c18b11b12ba134fa", + "skills/gsd-resume-work/SKILL.md": "fa2ee37470c6ae07", + "skills/gsd-review-backlog/SKILL.md": "d8a150558cc9326a", + "skills/gsd-review/SKILL.md": "f9b5e25043b667ac", + "skills/gsd-secure-phase/SKILL.md": "47a4ecd2aa680fda", + "skills/gsd-settings/SKILL.md": "fda7af8331acd352", + "skills/gsd-ship/SKILL.md": "4529b04a357cdacd", + "skills/gsd-sketch/SKILL.md": "eea837cc70ef1238", + "skills/gsd-spec-phase/SKILL.md": "6b35d59f65e2e607", + "skills/gsd-spike/SKILL.md": "63b5093cbc7978ca", + "skills/gsd-stats/SKILL.md": "1cef40c0a0e7b19b", + "skills/gsd-surface/SKILL.md": "963aa5a09d8e3695", + "skills/gsd-thread/SKILL.md": "4d91aab9f5ed4ca7", + "skills/gsd-ui-phase/SKILL.md": "b2412418ebf0dfb8", + "skills/gsd-ui-review/SKILL.md": "e49734488684fd9a", + "skills/gsd-ultraplan-phase/SKILL.md": "c71b716028d9ca24", + "skills/gsd-undo/SKILL.md": "c03cd1ab3c78cec4", + "skills/gsd-update/SKILL.md": "536ee29e2c205cdd", + "skills/gsd-validate-phase/SKILL.md": "4c9058e7240bc28b", + "skills/gsd-verify-work/SKILL.md": "82bd049e7ea36a5a", + "skills/gsd-workspace/SKILL.md": "f0d1512b46227344", + "skills/gsd-workstreams/SKILL.md": "737841783c7fdd4f" +} diff --git a/tests/fixtures/golden-install-parity/augment.json b/tests/fixtures/golden-install-parity/augment.json new file mode 100644 index 000000000..e674db07b --- /dev/null +++ b/tests/fixtures/golden-install-parity/augment.json @@ -0,0 +1,473 @@ +{ + ".gsd-profile": "0e716a5fef4e6dc1", + ".gsd/defaults.json": "560664b045e645cb", + "agents/gsd-advisor-researcher.md": "7dfdadd98ec1cccc", + "agents/gsd-ai-researcher.md": "b5e0e3ec6ffffed0", + "agents/gsd-assumptions-analyzer.md": "dc5e9973ff0740d7", + "agents/gsd-code-fixer.md": "88482f4ae6550082", + "agents/gsd-code-reviewer.md": "c96795dcb3072466", + "agents/gsd-codebase-mapper.md": "57432984d78ad147", + "agents/gsd-debug-session-manager.md": "69fe2698d5a997c6", + "agents/gsd-debugger.md": "6fb189777b25c9f0", + "agents/gsd-doc-classifier.md": "f61cb0fad0d4f243", + "agents/gsd-doc-synthesizer.md": "043847ebefbb9b7c", + "agents/gsd-doc-verifier.md": "b3433e0db51e1c60", + "agents/gsd-doc-writer.md": "ac68cddd32591ca3", + "agents/gsd-domain-researcher.md": "671c9ea949889c4a", + "agents/gsd-eval-auditor.md": "5b37acd7d551a166", + "agents/gsd-eval-planner.md": "a4a5b4b3f7828ba3", + "agents/gsd-executor.md": "1e64e29a5a6627e4", + "agents/gsd-framework-selector.md": "4b77eebbe9288d80", + "agents/gsd-integration-checker.md": "f6a85843ce7160bd", + "agents/gsd-intel-updater.md": "f8ac501c61557a17", + "agents/gsd-mempalace-curator.md": "63676937f8970bf3", + "agents/gsd-nyquist-auditor.md": "4b04783b66dc87d5", + "agents/gsd-pattern-mapper.md": "43c6021cf7caabfa", + "agents/gsd-phase-researcher.md": "612c14156eb2bc7e", + "agents/gsd-plan-checker.md": "9897ddeef3f85f08", + "agents/gsd-planner.md": "9a6a34c04282c471", + "agents/gsd-project-researcher.md": "d381c3efdbc90143", + "agents/gsd-research-synthesizer.md": "76913d1fefa9017e", + "agents/gsd-roadmapper.md": "33361cec9e3e0da1", + "agents/gsd-security-auditor.md": "91f42f9b3c811499", + "agents/gsd-ui-auditor.md": "fe8e959f969e7d92", + "agents/gsd-ui-checker.md": "4cf947a98db6410e", + "agents/gsd-ui-researcher.md": "3f8646572e9c3ec1", + "agents/gsd-user-profiler.md": "622220df0654b6bf", + "agents/gsd-verifier.md": "b1108277a4e858e3", + "commands/gsd-add-tests.md": "3608d0cf4b515103", + "commands/gsd-ai-integration-phase.md": "70843d4904743f7e", + "commands/gsd-audit-fix.md": "1b805946362c4f19", + "commands/gsd-audit-milestone.md": "046573a643714178", + "commands/gsd-audit-uat.md": "d3de44d1c3e59b57", + "commands/gsd-autonomous.md": "44598590349df325", + "commands/gsd-capture.md": "a4e4537bae4c90d3", + "commands/gsd-cleanup.md": "313e0443e1ae1557", + "commands/gsd-code-review.md": "1a32afb26a3a2926", + "commands/gsd-complete-milestone.md": "7f62fe57c67173fb", + "commands/gsd-config.md": "e00bef4533ce1648", + "commands/gsd-debug.md": "6ca109f930fe4b5a", + "commands/gsd-discuss-phase.md": "d5fc70a10ecf4c03", + "commands/gsd-docs-update.md": "58252664de1cc9d8", + "commands/gsd-eval-review.md": "b24d1b556dccf306", + "commands/gsd-execute-phase.md": "6db2b511bdfc7ee6", + "commands/gsd-explore.md": "cf09cdbd70a5320c", + "commands/gsd-extract-learnings.md": "46bfbbbb66207a43", + "commands/gsd-fast.md": "aceb53156d0dea5b", + "commands/gsd-forensics.md": "1be3c723d76f089a", + "commands/gsd-graphify.md": "ae5de1d629fcfa72", + "commands/gsd-health.md": "1a2707c6bc28c8fe", + "commands/gsd-help.md": "0d8c5a849465d771", + "commands/gsd-import.md": "5931425062f516ec", + "commands/gsd-inbox.md": "c218298db8b36e31", + "commands/gsd-ingest-docs.md": "6718b04c597a5428", + "commands/gsd-manager.md": "8f4ae79774902293", + "commands/gsd-map-codebase.md": "f3a06c4c7503f243", + "commands/gsd-mempalace-capture.md": "016a90f0eb7493ac", + "commands/gsd-mempalace-recall.md": "babb21998c7e6a6f", + "commands/gsd-milestone-summary.md": "ff5e11e6b33d4b5e", + "commands/gsd-mvp-phase.md": "ffc5905aed8f450a", + "commands/gsd-new-milestone.md": "e9ddaec1dd29d023", + "commands/gsd-new-project.md": "56552a324cff669e", + "commands/gsd-ns-context.md": "011c44e7aa46e64a", + "commands/gsd-ns-ideate.md": "edc5e543512dd48a", + "commands/gsd-ns-manage.md": "0409d810e499357f", + "commands/gsd-ns-project.md": "8dfd1b9a2ffe86ac", + "commands/gsd-ns-review.md": "3766ed10827882a0", + "commands/gsd-ns-workflow.md": "87910975ac92d103", + "commands/gsd-pause-work.md": "4fb032f72238fe33", + "commands/gsd-phase.md": "4920d15d779329eb", + "commands/gsd-plan-phase.md": "e74f3cb7a10cbb83", + "commands/gsd-plan-review-convergence.md": "3e4aff8f9ec6f8d8", + "commands/gsd-pr-branch.md": "e168fcd545d72d0d", + "commands/gsd-profile-user.md": "ffd9c2feb4c69f11", + "commands/gsd-progress.md": "0b4d5b73a6c5e958", + "commands/gsd-quick.md": "a6124a2443394092", + "commands/gsd-resume-work.md": "e54b929de88b11ce", + "commands/gsd-review-backlog.md": "6e8a0417fab95cd3", + "commands/gsd-review.md": "4403de207a9c2582", + "commands/gsd-secure-phase.md": "a2320ecca4cb160b", + "commands/gsd-settings.md": "53b90624a70fd530", + "commands/gsd-ship.md": "81136d903d261b33", + "commands/gsd-sketch.md": "0c44d54d15c8f96f", + "commands/gsd-spec-phase.md": "8bb332566911dfd9", + "commands/gsd-spike.md": "a99190d9496c6ac0", + "commands/gsd-stats.md": "58b4d86a5390e243", + "commands/gsd-surface.md": "acab871ec856e353", + "commands/gsd-thread.md": "51be0cdeb925ab28", + "commands/gsd-ui-phase.md": "75d4be44797ccef7", + "commands/gsd-ui-review.md": "9191082973068dbf", + "commands/gsd-ultraplan-phase.md": "a0cfcc5970e77341", + "commands/gsd-undo.md": "c62f376b4a0af5b1", + "commands/gsd-update.md": "bda7815908d9977b", + "commands/gsd-validate-phase.md": "948bdde83cc66341", + "commands/gsd-verify-work.md": "1cb62ea69b117acb", + "commands/gsd-workspace.md": "dd1bc09d2b768e0b", + "commands/gsd-workstreams.md": "52ab9c585d3a00f3", + "gsd-core/CHANGELOG.md": "e141e3fb369ff712", + "gsd-core/VERSION": "562368b20a64be95", + "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", + "gsd-core/bin/gsd-tools.cjs": "b7968e3e3af00249", + "gsd-core/bin/gsd_run": "62d9b647ede212e6", + "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", + "gsd-core/bin/shared/config-schema.manifest.json": "67e4addbfd248a7c", + "gsd-core/bin/shared/model-catalog.json": "dbe26e683236d8c6", + "gsd-core/bin/shared/runtime-aliases.manifest.json": "f6c8b3af10dcfdc4", + "gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904", + "gsd-core/contexts/dev.md": "dcb0de9dce33cf41", + "gsd-core/contexts/research.md": "b3285d8e7209cc3b", + "gsd-core/contexts/review.md": "dc578fdd74bbea11", + "gsd-core/references/agent-contracts.md": "ff65e633c656c0d2", + "gsd-core/references/ai-evals.md": "b5afa786b938671e", + "gsd-core/references/ai-frameworks.md": "f827de93dde124eb", + "gsd-core/references/artifact-types.md": "a6d2e1f9453ffbf5", + "gsd-core/references/autonomous-smart-discuss.md": "2fc710cde0ec7785", + "gsd-core/references/checkpoints.md": "6aa620c6ca38bdf0", + "gsd-core/references/common-bug-patterns.md": "780145be56352626", + "gsd-core/references/context-budget.md": "f1ce57bf418824af", + "gsd-core/references/continuation-format.md": "ce73e57d053ab8a7", + "gsd-core/references/debugger-philosophy.md": "0466f95a3d6bfcd3", + "gsd-core/references/decimal-phase-calculation.md": "46b5ba045852c474", + "gsd-core/references/doc-conflict-engine.md": "67d019d23e17f934", + "gsd-core/references/domain-probes.md": "62d23ed1992c48a9", + "gsd-core/references/edge-probe-fixtures/01-round-half-even/expected-coverage.json": "72d1e29cedc854ec", + "gsd-core/references/edge-probe-fixtures/01-round-half-even/requirements.json": "fbc1b355d8625eeb", + "gsd-core/references/edge-probe-fixtures/02-merge-intervals/expected-coverage.json": "fad67dcc8294f6da", + "gsd-core/references/edge-probe-fixtures/02-merge-intervals/requirements.json": "30a78ee9ce3473ea", + "gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/expected-coverage.json": "66dd60957fee45f0", + "gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/requirements.json": "47fca61f076835fa", + "gsd-core/references/edge-probe-fixtures/04-money-rounding/expected-coverage.json": "72d1e29cedc854ec", + "gsd-core/references/edge-probe-fixtures/04-money-rounding/requirements.json": "80f04f5c04fb24cf", + "gsd-core/references/edge-probe-fixtures/05-list-dedupe/expected-coverage.json": "fad67dcc8294f6da", + "gsd-core/references/edge-probe-fixtures/05-list-dedupe/requirements.json": "d38147adb0e5b342", + "gsd-core/references/edge-probe-fixtures/06-resolved-mixed/expected-coverage.json": "bc552c01939bf4f8", + "gsd-core/references/edge-probe-fixtures/06-resolved-mixed/requirements.json": "30a78ee9ce3473ea", + "gsd-core/references/edge-probe-fixtures/06-resolved-mixed/resolutions.json": "688ec62c13e08afe", + "gsd-core/references/edge-probe.md": "5687eba25a078561", + "gsd-core/references/execute-mvp-tdd.md": "a98a270a7ab126bc", + "gsd-core/references/execute-phase-between-wave-reset.md": "3ad96ca0f7fee37e", + "gsd-core/references/execute-phase-context-guard.md": "982006c1f3364242", + "gsd-core/references/execute-phase-wave-guard.md": "de9ac22cead4cfd8", + "gsd-core/references/executor-examples.md": "ba59243ed45c8ab1", + "gsd-core/references/few-shot-examples/plan-checker.md": "2574808188ac9de4", + "gsd-core/references/few-shot-examples/verifier.md": "5badee4560b14ae8", + "gsd-core/references/gate-prompts.md": "e69f5993ab944d80", + "gsd-core/references/gates.md": "7dc9fd3a3d6217c6", + "gsd-core/references/git-integration.md": "77bf9dff38b2c9d4", + "gsd-core/references/git-planning-commit.md": "f897a15ebfc3f5a7", + "gsd-core/references/ios-scaffold.md": "5ef0cb7e0fac891f", + "gsd-core/references/loop-hook-dispatch.md": "32e5dfb4dba76987", + "gsd-core/references/mandatory-initial-read.md": "fe59abce693717cf", + "gsd-core/references/model-profile-resolution.md": "f32bb05102839767", + "gsd-core/references/model-profiles.md": "d3513252c5de5465", + "gsd-core/references/mvp-concepts.md": "72f7e8b1f8ae7118", + "gsd-core/references/phase-argument-parsing.md": "e5bbb985f3bc3e34", + "gsd-core/references/planner-antipatterns.md": "7ed54ec1e2cc54ac", + "gsd-core/references/planner-chunked.md": "79fe674221e738e6", + "gsd-core/references/planner-gap-closure.md": "76bee257911413e7", + "gsd-core/references/planner-graphify-auto-update.md": "6aeffd9097e25e22", + "gsd-core/references/planner-guidance.md": "96486cac2f7885e6", + "gsd-core/references/planner-human-verify-mode.md": "56d05e841630b3f4", + "gsd-core/references/planner-interface-context.md": "b28fa3da6ae739a8", + "gsd-core/references/planner-load-graph-context.md": "ca7a7af3f35ae61b", + "gsd-core/references/planner-mvp-mode.md": "ec33050db81101a8", + "gsd-core/references/planner-reviews.md": "dda0193a0fbd4947", + "gsd-core/references/planner-revision.md": "86ba8a511f081f05", + "gsd-core/references/planner-source-audit.md": "7de5bdb07232ce0b", + "gsd-core/references/planning-config.md": "1c3a3aae2ae89e83", + "gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json": "f10df472f2846cc6", + "gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json": "31e8a781eeffe020", + "gsd-core/references/prohibition-probe-fixtures/03-multi-prohibition/expected.json": "70a532a7cc1b6ae8", + "gsd-core/references/prohibition-probe.md": "605dc3f5a118ff3b", + "gsd-core/references/project-skills-discovery.md": "c155e03dce8dc3c2", + "gsd-core/references/questioning.md": "a8c988cab05f4651", + "gsd-core/references/research-documentation-lookup.md": "c070007d1d72ab71", + "gsd-core/references/research-philosophy.md": "62930e66cc979c1a", + "gsd-core/references/research-verification-protocol.md": "9c38c9d9a687e679", + "gsd-core/references/revision-loop.md": "e55ff32dd98c63df", + "gsd-core/references/scout-codebase.md": "ba266ecc18fbf172", + "gsd-core/references/security-asvs-levels.md": "4774fac3b94b6ca8", + "gsd-core/references/skeleton-template.md": "528691d1f0efa878", + "gsd-core/references/sketch-interactivity.md": "7d982fe877e1e1cc", + "gsd-core/references/sketch-theme-system.md": "33e2e96e450456f8", + "gsd-core/references/sketch-tooling.md": "df6c4f24c1c27611", + "gsd-core/references/sketch-variant-patterns.md": "66c197aa4fb52810", + "gsd-core/references/spidr-splitting.md": "074ac154c0e4f906", + "gsd-core/references/tdd.md": "e4708ede157478b6", + "gsd-core/references/thinking-models-debug.md": "2da61022b16c4e7c", + "gsd-core/references/thinking-models-execution.md": "dcc650a8b5f3e049", + "gsd-core/references/thinking-models-planning.md": "7e19462313fa028f", + "gsd-core/references/thinking-models-research.md": "5f6bf3f3b889c6e4", + "gsd-core/references/thinking-models-verification.md": "a71a933d51ca3d8d", + "gsd-core/references/thinking-partner.md": "41069529ef776e39", + "gsd-core/references/ui-brand.md": "48717bcfcd63bd27", + "gsd-core/references/universal-anti-patterns.md": "6a1245050b21df01", + "gsd-core/references/untrusted-input-boundary.md": "d33b80d4d348599a", + "gsd-core/references/user-profiling.md": "b50416fe57c1b321", + "gsd-core/references/user-story-template.md": "0cc50e06a144ff8a", + "gsd-core/references/verification-overrides.md": "8213de9bd62283b6", + "gsd-core/references/verification-patterns.md": "72f4d4814fdae9a8", + "gsd-core/references/verify-mvp-mode.md": "534bdc7f2432903a", + "gsd-core/references/workstream-flag.md": "ca99ca79e716f0f5", + "gsd-core/references/worktree-branch-check.md": "21d9c31bf6542b93", + "gsd-core/references/worktree-path-safety.md": "3c8d74756f9b16a8", + "gsd-core/templates/AI-SPEC.md": "efa1f8354bd3a24b", + "gsd-core/templates/DEBUG.md": "a13470b82b1935e7", + "gsd-core/templates/README.md": "93d3426fc64e2c12", + "gsd-core/templates/SECURITY.md": "b628f7f1c6d2328f", + "gsd-core/templates/UAT.md": "9e296471b97ebcec", + "gsd-core/templates/UI-SPEC.md": "20ca56a4e3e21f01", + "gsd-core/templates/VALIDATION.md": "f53e0ca061d3528e", + "gsd-core/templates/claude-md.md": "d1d333e4b963c0d2", + "gsd-core/templates/codebase/architecture.md": "6be88214162fdd89", + "gsd-core/templates/codebase/concerns.md": "efa26d1fb5132f25", + "gsd-core/templates/codebase/conventions.md": "c2e07698dad6b364", + "gsd-core/templates/codebase/integrations.md": "39bd23c71eedd564", + "gsd-core/templates/codebase/stack.md": "116e7e67dd87ddec", + "gsd-core/templates/codebase/structure.md": "222997133232a6f5", + "gsd-core/templates/codebase/testing.md": "76abff7f2050c9ea", + "gsd-core/templates/config.json": "1473dff11272d3c4", + "gsd-core/templates/context.md": "69b01e7909ea3f66", + "gsd-core/templates/continue-here.md": "f522a51b6895fba8", + "gsd-core/templates/copilot-instructions.md": "aea34bc52ff548ea", + "gsd-core/templates/debug-subagent-prompt.md": "920656683dedb869", + "gsd-core/templates/dev-preferences.md": "88d0a65ec0993a3a", + "gsd-core/templates/discovery.md": "9a0e0935cc825dbc", + "gsd-core/templates/discussion-log.md": "cac1b48ec0f4dcb8", + "gsd-core/templates/milestone-archive.md": "591b6decdc0c0e51", + "gsd-core/templates/milestone.md": "74d2f750ae9f4a9c", + "gsd-core/templates/phase-prompt.md": "b811bf951092df2f", + "gsd-core/templates/planner-subagent-prompt.md": "ebf29dbb27042370", + "gsd-core/templates/project.md": "ab4af6becca2bb23", + "gsd-core/templates/requirements.md": "a44de4c2f146e473", + "gsd-core/templates/research-project/ARCHITECTURE.md": "746b9ef791d758b0", + "gsd-core/templates/research-project/FEATURES.md": "f2b800de5df91b0f", + "gsd-core/templates/research-project/PITFALLS.md": "3ef75fa768422eec", + "gsd-core/templates/research-project/STACK.md": "82c85799ac4dd344", + "gsd-core/templates/research-project/SUMMARY.md": "dceb2f346388839d", + "gsd-core/templates/research.md": "88ce0920417091d0", + "gsd-core/templates/retrospective.md": "03981e30dd760103", + "gsd-core/templates/roadmap.md": "e4e35a9eb5dd4d4f", + "gsd-core/templates/spec.md": "26d55bce940f0288", + "gsd-core/templates/state.md": "4d123aa6cea167fe", + "gsd-core/templates/summary-complex.md": "a5e40574fd8894dc", + "gsd-core/templates/summary-minimal.md": "7d09b5e709e2e67c", + "gsd-core/templates/summary-standard.md": "e8d9cf4a8377cdff", + "gsd-core/templates/summary.md": "23c40f6503b3ea98", + "gsd-core/templates/user-profile.md": "20749f23e4c413fc", + "gsd-core/templates/user-setup.md": "78b7d718b6e8d67c", + "gsd-core/templates/verification-report.md": "dd5faa6254183731", + "gsd-core/workflows/_runtime-launcher.snippet.sh": "c0994e00f976dbfb", + "gsd-core/workflows/add-backlog.md": "1153d35058e3e24f", + "gsd-core/workflows/add-phase.md": "b98c2fc51cd123ba", + "gsd-core/workflows/add-tests.md": "2871f7e6409ba89e", + "gsd-core/workflows/add-todo.md": "3b913840c1f490b2", + "gsd-core/workflows/ai-integration-phase.md": "ad2ff20091d1c2c0", + "gsd-core/workflows/analyze-dependencies.md": "52942af10f140717", + "gsd-core/workflows/audit-fix.md": "eaead7ea85761e5b", + "gsd-core/workflows/audit-milestone.md": "fe7185bb70eafdb9", + "gsd-core/workflows/audit-uat.md": "ca6f1f0ef174f793", + "gsd-core/workflows/autonomous.md": "ba6828223ba13c1f", + "gsd-core/workflows/check-todos.md": "1e3026abe782bd36", + "gsd-core/workflows/cleanup.md": "5cf0772521c192a4", + "gsd-core/workflows/code-review-fix.md": "b442c670f8c1fe93", + "gsd-core/workflows/code-review.md": "1bfa3bf542e30c5e", + "gsd-core/workflows/complete-milestone.md": "8a4cfb41a4de5a99", + "gsd-core/workflows/debug.md": "da62e7a62c113b29", + "gsd-core/workflows/diagnose-issues.md": "a5f15332b3833a80", + "gsd-core/workflows/discovery-phase.md": "3ba7cfb89fb1e761", + "gsd-core/workflows/discuss-phase-assumptions.md": "a365fdca7f7281ad", + "gsd-core/workflows/discuss-phase-power.md": "0841f7dc6e9a054a", + "gsd-core/workflows/discuss-phase.md": "b2538b3f8a15f7de", + "gsd-core/workflows/discuss-phase/modes/advisor.md": "c7d4a3ff91655d24", + "gsd-core/workflows/discuss-phase/modes/all.md": "fa70d79066562e54", + "gsd-core/workflows/discuss-phase/modes/analyze.md": "da0788f3be7f8105", + "gsd-core/workflows/discuss-phase/modes/auto.md": "3333b280afd68321", + "gsd-core/workflows/discuss-phase/modes/batch.md": "6946597770e2d448", + "gsd-core/workflows/discuss-phase/modes/chain.md": "ca3cc1ddfd19cd9d", + "gsd-core/workflows/discuss-phase/modes/default.md": "67d1b67f61f03966", + "gsd-core/workflows/discuss-phase/modes/power.md": "fdc7a728eaaa2261", + "gsd-core/workflows/discuss-phase/modes/text.md": "da6d45207da4a988", + "gsd-core/workflows/discuss-phase/templates/checkpoint.json": "e3bc3dca49db59eb", + "gsd-core/workflows/discuss-phase/templates/context.md": "6cd929e989fe2b0f", + "gsd-core/workflows/discuss-phase/templates/discussion-log.md": "1bbd7703f11128e1", + "gsd-core/workflows/do.md": "152e9c5c512f79cb", + "gsd-core/workflows/docs-update.md": "806ada831b961116", + "gsd-core/workflows/edit-phase.md": "e5624ac6e3f8bef5", + "gsd-core/workflows/eval-review.md": "dfcfb4f8ce031fae", + "gsd-core/workflows/execute-phase.md": "abe70d6b2776afd4", + "gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md": "8898e0ea533cc643", + "gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md": "7ebb7d1af6082028", + "gsd-core/workflows/execute-phase/steps/post-merge-gate.md": "abd2aca069c04a80", + "gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md": "be84efbd71e1513e", + "gsd-core/workflows/execute-plan.md": "502a2498f6b27e38", + "gsd-core/workflows/explore.md": "11523b829cc67c29", + "gsd-core/workflows/extract-learnings.md": "a2e4836abdeb1fdb", + "gsd-core/workflows/fast.md": "94136fb570d20a9d", + "gsd-core/workflows/forensics.md": "76bea6e254652c2c", + "gsd-core/workflows/graduation.md": "00d1b7fe7ed8578b", + "gsd-core/workflows/health.md": "e3cb8ff4e01e9e53", + "gsd-core/workflows/help.md": "5d040504b9ab35e3", + "gsd-core/workflows/help/modes/brief.md": "5fe077fbbdb5d9f7", + "gsd-core/workflows/help/modes/default.md": "cc8ef97de634c995", + "gsd-core/workflows/help/modes/full.md": "39703b79d77bf691", + "gsd-core/workflows/help/modes/topic.md": "d7c60d488810fbd6", + "gsd-core/workflows/import.md": "97861d522319d56e", + "gsd-core/workflows/inbox.md": "437f981ef9ae7b26", + "gsd-core/workflows/ingest-docs.md": "77d9103d8d5432f3", + "gsd-core/workflows/insert-phase.md": "621f9e75725542b1", + "gsd-core/workflows/list-phase-assumptions.md": "53dd4b69536c1bc3", + "gsd-core/workflows/list-seeds.md": "0a466d04df300854", + "gsd-core/workflows/list-workspaces.md": "6e5c402000090d7b", + "gsd-core/workflows/manager.md": "a31f7e255dd7d01d", + "gsd-core/workflows/map-codebase.md": "582bddef590de9c6", + "gsd-core/workflows/milestone-summary.md": "f956783f7d063963", + "gsd-core/workflows/mvp-phase.md": "bfa668107be972ed", + "gsd-core/workflows/new-milestone.md": "6d07ca2fc7aec3a4", + "gsd-core/workflows/new-project.md": "e6855b300fcac71e", + "gsd-core/workflows/new-workspace.md": "fdd63a9adf030cb1", + "gsd-core/workflows/next.md": "48bd4b5d54f0d1f8", + "gsd-core/workflows/node-repair.md": "07a1628e5a1ff96b", + "gsd-core/workflows/note.md": "5a99eb396c744619", + "gsd-core/workflows/pause-work.md": "7bcbdf27ba957c8b", + "gsd-core/workflows/plan-milestone-gaps.md": "02fee851c82e3b25", + "gsd-core/workflows/plan-phase.md": "fe6b786141eab878", + "gsd-core/workflows/plan-review-convergence.md": "10007f8382864bcd", + "gsd-core/workflows/plant-seed.md": "7b795d7a1b4c9f06", + "gsd-core/workflows/pr-branch.md": "f2a35833fe784a53", + "gsd-core/workflows/profile-user.md": "02bc967c299a0136", + "gsd-core/workflows/progress.md": "057699b34b6198f7", + "gsd-core/workflows/quick.md": "a7fdf2de3ae30c95", + "gsd-core/workflows/reapply-patches.md": "7915e40411490bbe", + "gsd-core/workflows/remove-phase.md": "d030f80ca0df4fa9", + "gsd-core/workflows/remove-workspace.md": "b8817a3a5907f5bc", + "gsd-core/workflows/resume-project.md": "b18b51fd15cbce95", + "gsd-core/workflows/review.md": "18ab093986814bb3", + "gsd-core/workflows/scan.md": "54ff1ff60041d065", + "gsd-core/workflows/secure-phase.md": "1d1c66ad9ea01bd2", + "gsd-core/workflows/session-report.md": "2e5b1205324ddefa", + "gsd-core/workflows/settings-advanced.md": "e7e50f99e4de3872", + "gsd-core/workflows/settings-integrations.md": "351c5a6d0d5e27ed", + "gsd-core/workflows/settings.md": "48d337eb7e3f141b", + "gsd-core/workflows/ship.md": "d26fb5d3e965642a", + "gsd-core/workflows/sketch-wrap-up.md": "c03f64e834b69540", + "gsd-core/workflows/sketch.md": "04e0758c1a58881e", + "gsd-core/workflows/spec-phase.md": "2aa0353147153776", + "gsd-core/workflows/spike-wrap-up.md": "c86e04a0feb220a5", + "gsd-core/workflows/spike.md": "53127654e77256bf", + "gsd-core/workflows/stats.md": "01c24349370a0e6d", + "gsd-core/workflows/sync-skills.md": "b505e6f8331c0918", + "gsd-core/workflows/thread.md": "c26ca43fdf928d46", + "gsd-core/workflows/transition.md": "eee3435817fab185", + "gsd-core/workflows/ui-phase.md": "e81783508b8b6819", + "gsd-core/workflows/ui-review.md": "1ad3654435000881", + "gsd-core/workflows/ultraplan-phase.md": "66c17ad2e555e262", + "gsd-core/workflows/undo.md": "96d2775f008b3a85", + "gsd-core/workflows/update.md": "231e7c305b40c417", + "gsd-core/workflows/validate-phase.md": "50f37b705b6e44fb", + "gsd-core/workflows/verify-phase.md": "968d569ea4ef377f", + "gsd-core/workflows/verify-work.md": "6f9c666386cb6d7e", + "hooks/gsd-check-update-worker.js": "e42be7414a05e99d", + "hooks/gsd-check-update.js": "b3333951b2091807", + "hooks/gsd-config-reload.js": "17bf778d432b3d2a", + "hooks/gsd-context-monitor.js": "11e88809e2cdc331", + "hooks/gsd-cursor-post-tool.js": "d61ee04f6ee7858c", + "hooks/gsd-cursor-session-start.js": "148b8ec4e2c97f00", + "hooks/gsd-ensure-canonical-path.js": "3499b6e6b453dc59", + "hooks/gsd-graphify-update.sh": "396ebda3c6705dc9", + "hooks/gsd-phase-boundary.sh": "6aa3ba9af3d465d9", + "hooks/gsd-prompt-guard.js": "4b08c2dce0233e2d", + "hooks/gsd-read-guard.js": "b602f88f046a7551", + "hooks/gsd-read-injection-scanner.js": "ca99873d0bf8b4ba", + "hooks/gsd-session-state.sh": "b1496e6a5204a6df", + "hooks/gsd-statusline.js": "9b7005c36891671d", + "hooks/gsd-update-banner.js": "74817c820b7a4ec1", + "hooks/gsd-validate-commit.sh": "14d3d966c74dc310", + "hooks/gsd-workflow-guard.js": "e22b9fb57f0e64f6", + "hooks/gsd-worktree-path-guard.js": "7921523b20372a1e", + "hooks/lib/git-cmd.js": "268ba15992ca0b23", + "hooks/lib/gsd-graphify-rebuild.sh": "66af89601074d2a9", + "hooks/managed-hooks-registry.cjs": "f46a329fcfefa465", + "package.json": "dbf8353f77358bc1", + "scripts/changeset/README.md": "86ff89331dfd94b2", + "scripts/changeset/cli.cjs": "68f92a344b199271", + "scripts/changeset/github-release-notes.cjs": "795677f0c009b132", + "scripts/changeset/lint.cjs": "0066faed159154f0", + "scripts/changeset/new.cjs": "4991e21fd17f5541", + "scripts/changeset/parse.cjs": "f9a949cbcab56445", + "scripts/changeset/render.cjs": "e47bc3e1587c3cae", + "scripts/changeset/serialize.cjs": "ac0b8fe6f87cdb0e", + "scripts/fix-slash-commands.cjs": "0519742531ff3529", + "scripts/lib/allowlist-ratchet.cjs": "ffaceaac3efc2660", + "scripts/lib/cli-exit.cjs": "612d0c372c75b7e7", + "skills/gsd-ns-context/SKILL.md": "90ff2716402f8f61", + "skills/gsd-ns-context/skills/docs-update/SKILL.md": "e7ab14a984f7462c", + "skills/gsd-ns-context/skills/extract-learnings/SKILL.md": "48846bf26ccaf0e7", + "skills/gsd-ns-context/skills/graphify/SKILL.md": "3d6341614add6ccd", + "skills/gsd-ns-context/skills/map-codebase/SKILL.md": "51d6b970dca3c28e", + "skills/gsd-ns-context/skills/mempalace-capture/SKILL.md": "c585b152e9d9d6d3", + "skills/gsd-ns-context/skills/mempalace-recall/SKILL.md": "1a3267902234c607", + "skills/gsd-ns-ideate/SKILL.md": "d70360a5f7e8ac90", + "skills/gsd-ns-ideate/skills/capture/SKILL.md": "74651f6959cfbbd3", + "skills/gsd-ns-ideate/skills/explore/SKILL.md": "bb8413dc283a5bb1", + "skills/gsd-ns-ideate/skills/sketch/SKILL.md": "3c1218159bbf4eb0", + "skills/gsd-ns-ideate/skills/spec-phase/SKILL.md": "2f88d0fc0c1abefe", + "skills/gsd-ns-ideate/skills/spike/SKILL.md": "799932a60c21fddc", + "skills/gsd-ns-manage/SKILL.md": "123d32471bc44cee", + "skills/gsd-ns-manage/skills/cleanup/SKILL.md": "cc78257dc49e91ab", + "skills/gsd-ns-manage/skills/config/SKILL.md": "c99f48d175be9c5a", + "skills/gsd-ns-manage/skills/health/SKILL.md": "f3c7f00b39802522", + "skills/gsd-ns-manage/skills/help/SKILL.md": "fefeb84914e1ab94", + "skills/gsd-ns-manage/skills/inbox/SKILL.md": "9a9224c09c095438", + "skills/gsd-ns-manage/skills/manager/SKILL.md": "e810eb24d1b0cb42", + "skills/gsd-ns-manage/skills/pause-work/SKILL.md": "c3e1186348abbaca", + "skills/gsd-ns-manage/skills/pr-branch/SKILL.md": "b5be687b86830368", + "skills/gsd-ns-manage/skills/resume-work/SKILL.md": "753c45ba8c91e640", + "skills/gsd-ns-manage/skills/settings/SKILL.md": "8751c29421208a00", + "skills/gsd-ns-manage/skills/ship/SKILL.md": "7498424e965545e1", + "skills/gsd-ns-manage/skills/stats/SKILL.md": "0bce39e8f3b64f5e", + "skills/gsd-ns-manage/skills/surface/SKILL.md": "0644bd7dbeeb0a73", + "skills/gsd-ns-manage/skills/thread/SKILL.md": "fbe591e8162f8b1c", + "skills/gsd-ns-manage/skills/undo/SKILL.md": "5ffc86a2c67aa9df", + "skills/gsd-ns-manage/skills/update/SKILL.md": "c8165b8085ba106a", + "skills/gsd-ns-manage/skills/workspace/SKILL.md": "98cc03aa8c735e9d", + "skills/gsd-ns-manage/skills/workstreams/SKILL.md": "94d00b5116523f36", + "skills/gsd-ns-project/SKILL.md": "4d7e3c3870536dc9", + "skills/gsd-ns-project/skills/audit-milestone/SKILL.md": "6d4c77390c549fc7", + "skills/gsd-ns-project/skills/complete-milestone/SKILL.md": "0af489daf6fd3f16", + "skills/gsd-ns-project/skills/import/SKILL.md": "a3cfa7abc5ddd26d", + "skills/gsd-ns-project/skills/ingest-docs/SKILL.md": "53e838f8f509d0ac", + "skills/gsd-ns-project/skills/milestone-summary/SKILL.md": "18dc0c134cb62657", + "skills/gsd-ns-project/skills/new-milestone/SKILL.md": "a94ff34b09e17a98", + "skills/gsd-ns-project/skills/new-project/SKILL.md": "81f681f54ca61f90", + "skills/gsd-ns-project/skills/profile-user/SKILL.md": "68b015a40d386bff", + "skills/gsd-ns-project/skills/review-backlog/SKILL.md": "0f25310aa0cd48f6", + "skills/gsd-ns-review/SKILL.md": "d7567fd9a75a5c21", + "skills/gsd-ns-review/skills/audit-fix/SKILL.md": "c085b5f9585e7fc8", + "skills/gsd-ns-review/skills/audit-uat/SKILL.md": "abbb16c85efd493d", + "skills/gsd-ns-review/skills/code-review/SKILL.md": "36dbfd5aa2d3c3c7", + "skills/gsd-ns-review/skills/debug/SKILL.md": "4b9bde6d213185f0", + "skills/gsd-ns-review/skills/eval-review/SKILL.md": "61d28536af6794c9", + "skills/gsd-ns-review/skills/forensics/SKILL.md": "e68fc285c39dad5e", + "skills/gsd-ns-review/skills/review/SKILL.md": "022745ee2379823c", + "skills/gsd-ns-review/skills/secure-phase/SKILL.md": "ff7949b3261c09fa", + "skills/gsd-ns-review/skills/ui-phase/SKILL.md": "d0e75ee06eaa0165", + "skills/gsd-ns-review/skills/ui-review/SKILL.md": "bdab072171d79877", + "skills/gsd-ns-review/skills/validate-phase/SKILL.md": "8972b413eb712cae", + "skills/gsd-ns-workflow/SKILL.md": "e0711a5522acc3ca", + "skills/gsd-ns-workflow/skills/add-tests/SKILL.md": "851084b695ab8328", + "skills/gsd-ns-workflow/skills/ai-integration-phase/SKILL.md": "34fa7aff585eb78f", + "skills/gsd-ns-workflow/skills/autonomous/SKILL.md": "1565a532889d3ea3", + "skills/gsd-ns-workflow/skills/discuss-phase/SKILL.md": "94ac4065ceeee80b", + "skills/gsd-ns-workflow/skills/execute-phase/SKILL.md": "67c9a6189f045de0", + "skills/gsd-ns-workflow/skills/fast/SKILL.md": "34b741cc323f5f15", + "skills/gsd-ns-workflow/skills/mvp-phase/SKILL.md": "f259f089a8d07a54", + "skills/gsd-ns-workflow/skills/phase/SKILL.md": "fe5b26417ee466be", + "skills/gsd-ns-workflow/skills/plan-phase/SKILL.md": "8cf23ecd6ac98069", + "skills/gsd-ns-workflow/skills/plan-review-convergence/SKILL.md": "93f68cc6a36de7f2", + "skills/gsd-ns-workflow/skills/progress/SKILL.md": "68bda87136db9fb3", + "skills/gsd-ns-workflow/skills/quick/SKILL.md": "014dec52d85dcb0e", + "skills/gsd-ns-workflow/skills/spec-phase/SKILL.md": "2f88d0fc0c1abefe", + "skills/gsd-ns-workflow/skills/ultraplan-phase/SKILL.md": "8673b6e4fc14d0ec", + "skills/gsd-ns-workflow/skills/verify-work/SKILL.md": "b92b0e8428a8e80c" +} diff --git a/tests/fixtures/golden-install-parity/claude.json b/tests/fixtures/golden-install-parity/claude.json new file mode 100644 index 000000000..89efd435a --- /dev/null +++ b/tests/fixtures/golden-install-parity/claude.json @@ -0,0 +1,402 @@ +{ + ".gsd-profile": "0e716a5fef4e6dc1", + "agents/gsd-advisor-researcher.md": "bfee29d91fae7482", + "agents/gsd-ai-researcher.md": "84b8bcfb941a695d", + "agents/gsd-assumptions-analyzer.md": "6790335368de9256", + "agents/gsd-code-fixer.md": "0b1249729381feaa", + "agents/gsd-code-reviewer.md": "dc0f53798e7be5bf", + "agents/gsd-codebase-mapper.md": "061507e9ffa4eb98", + "agents/gsd-debug-session-manager.md": "808092282a01943f", + "agents/gsd-debugger.md": "e3911b3005058023", + "agents/gsd-doc-classifier.md": "4740eb4c4f6ac294", + "agents/gsd-doc-synthesizer.md": "135bbdfb9053cbd8", + "agents/gsd-doc-verifier.md": "4232dcf9076e3566", + "agents/gsd-doc-writer.md": "4bc840f73065eec8", + "agents/gsd-domain-researcher.md": "f8be56555689a970", + "agents/gsd-eval-auditor.md": "a8f01cf6028bb351", + "agents/gsd-eval-planner.md": "112f6730f23854e3", + "agents/gsd-executor.md": "b46c52658b58525f", + "agents/gsd-framework-selector.md": "c350ee693cb1aa4e", + "agents/gsd-integration-checker.md": "a58d2e3e8dd86496", + "agents/gsd-intel-updater.md": "9fadfebd08f15244", + "agents/gsd-mempalace-curator.md": "61c387c3fd9bae78", + "agents/gsd-nyquist-auditor.md": "635fe8a78cff4cd4", + "agents/gsd-pattern-mapper.md": "b45b5e106775bec1", + "agents/gsd-phase-researcher.md": "c4e3719ee6b48cbd", + "agents/gsd-plan-checker.md": "6de9fa5a3e560fdf", + "agents/gsd-planner.md": "8c7db54ca9fe4ddb", + "agents/gsd-project-researcher.md": "09d8937aac2ee4be", + "agents/gsd-research-synthesizer.md": "4e93a673dd201b11", + "agents/gsd-roadmapper.md": "ae1a13e63babc134", + "agents/gsd-security-auditor.md": "9490f73c1434f91b", + "agents/gsd-ui-auditor.md": "00b1f627ae48d783", + "agents/gsd-ui-checker.md": "dd06843892f6b0c8", + "agents/gsd-ui-researcher.md": "85d7d6cc36388435", + "agents/gsd-user-profiler.md": "003276f85792cfda", + "agents/gsd-verifier.md": "0a0c618959bb00d2", + "gsd-core/CHANGELOG.md": "e141e3fb369ff712", + "gsd-core/VERSION": "562368b20a64be95", + "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", + "gsd-core/bin/gsd-tools.cjs": "b7968e3e3af00249", + "gsd-core/bin/gsd_run": "62d9b647ede212e6", + "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", + "gsd-core/bin/shared/config-schema.manifest.json": "67e4addbfd248a7c", + "gsd-core/bin/shared/model-catalog.json": "dbe26e683236d8c6", + "gsd-core/bin/shared/runtime-aliases.manifest.json": "f6c8b3af10dcfdc4", + "gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904", + "gsd-core/contexts/dev.md": "dcb0de9dce33cf41", + "gsd-core/contexts/research.md": "b3285d8e7209cc3b", + "gsd-core/contexts/review.md": "dc578fdd74bbea11", + "gsd-core/references/agent-contracts.md": "ff65e633c656c0d2", + "gsd-core/references/ai-evals.md": "b5afa786b938671e", + "gsd-core/references/ai-frameworks.md": "f827de93dde124eb", + "gsd-core/references/artifact-types.md": "8bd01fd75a2ba70e", + "gsd-core/references/autonomous-smart-discuss.md": "2fc710cde0ec7785", + "gsd-core/references/checkpoints.md": "6aa620c6ca38bdf0", + "gsd-core/references/common-bug-patterns.md": "780145be56352626", + "gsd-core/references/context-budget.md": "533eae480bfc4bb8", + "gsd-core/references/continuation-format.md": "580287399ad3ba68", + "gsd-core/references/debugger-philosophy.md": "0466f95a3d6bfcd3", + "gsd-core/references/decimal-phase-calculation.md": "46b5ba045852c474", + "gsd-core/references/doc-conflict-engine.md": "883d0a1b9d9ff96e", + "gsd-core/references/domain-probes.md": "762b965e84035b72", + "gsd-core/references/edge-probe-fixtures/01-round-half-even/expected-coverage.json": "72d1e29cedc854ec", + "gsd-core/references/edge-probe-fixtures/01-round-half-even/requirements.json": "fbc1b355d8625eeb", + "gsd-core/references/edge-probe-fixtures/02-merge-intervals/expected-coverage.json": "fad67dcc8294f6da", + "gsd-core/references/edge-probe-fixtures/02-merge-intervals/requirements.json": "30a78ee9ce3473ea", + "gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/expected-coverage.json": "66dd60957fee45f0", + "gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/requirements.json": "47fca61f076835fa", + "gsd-core/references/edge-probe-fixtures/04-money-rounding/expected-coverage.json": "72d1e29cedc854ec", + "gsd-core/references/edge-probe-fixtures/04-money-rounding/requirements.json": "80f04f5c04fb24cf", + "gsd-core/references/edge-probe-fixtures/05-list-dedupe/expected-coverage.json": "fad67dcc8294f6da", + "gsd-core/references/edge-probe-fixtures/05-list-dedupe/requirements.json": "d38147adb0e5b342", + "gsd-core/references/edge-probe-fixtures/06-resolved-mixed/expected-coverage.json": "bc552c01939bf4f8", + "gsd-core/references/edge-probe-fixtures/06-resolved-mixed/requirements.json": "30a78ee9ce3473ea", + "gsd-core/references/edge-probe-fixtures/06-resolved-mixed/resolutions.json": "688ec62c13e08afe", + "gsd-core/references/edge-probe.md": "5687eba25a078561", + "gsd-core/references/execute-mvp-tdd.md": "a98a270a7ab126bc", + "gsd-core/references/execute-phase-between-wave-reset.md": "3ad96ca0f7fee37e", + "gsd-core/references/execute-phase-context-guard.md": "a5a1058d35806a8e", + "gsd-core/references/execute-phase-wave-guard.md": "de9ac22cead4cfd8", + "gsd-core/references/executor-examples.md": "ba59243ed45c8ab1", + "gsd-core/references/few-shot-examples/plan-checker.md": "2574808188ac9de4", + "gsd-core/references/few-shot-examples/verifier.md": "5badee4560b14ae8", + "gsd-core/references/gate-prompts.md": "099c8d52e3562336", + "gsd-core/references/gates.md": "7dc9fd3a3d6217c6", + "gsd-core/references/git-integration.md": "5c70ef3203b7c9ce", + "gsd-core/references/git-planning-commit.md": "f897a15ebfc3f5a7", + "gsd-core/references/ios-scaffold.md": "5ef0cb7e0fac891f", + "gsd-core/references/loop-hook-dispatch.md": "32e5dfb4dba76987", + "gsd-core/references/mandatory-initial-read.md": "fe59abce693717cf", + "gsd-core/references/model-profile-resolution.md": "f32bb05102839767", + "gsd-core/references/model-profiles.md": "d482387de75a44bc", + "gsd-core/references/mvp-concepts.md": "3464783eaaef5c10", + "gsd-core/references/phase-argument-parsing.md": "e5bbb985f3bc3e34", + "gsd-core/references/planner-antipatterns.md": "7ed54ec1e2cc54ac", + "gsd-core/references/planner-chunked.md": "79fe674221e738e6", + "gsd-core/references/planner-gap-closure.md": "76bee257911413e7", + "gsd-core/references/planner-graphify-auto-update.md": "1ed614dfba72f2a3", + "gsd-core/references/planner-guidance.md": "140b1266eeb096cb", + "gsd-core/references/planner-human-verify-mode.md": "56d05e841630b3f4", + "gsd-core/references/planner-interface-context.md": "b28fa3da6ae739a8", + "gsd-core/references/planner-load-graph-context.md": "ca7a7af3f35ae61b", + "gsd-core/references/planner-mvp-mode.md": "ec33050db81101a8", + "gsd-core/references/planner-reviews.md": "da39eace09a10743", + "gsd-core/references/planner-revision.md": "86ba8a511f081f05", + "gsd-core/references/planner-source-audit.md": "7de5bdb07232ce0b", + "gsd-core/references/planning-config.md": "4435dfdc3381233f", + "gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json": "f10df472f2846cc6", + "gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json": "31e8a781eeffe020", + "gsd-core/references/prohibition-probe-fixtures/03-multi-prohibition/expected.json": "70a532a7cc1b6ae8", + "gsd-core/references/prohibition-probe.md": "52872d5393ea9daf", + "gsd-core/references/project-skills-discovery.md": "c155e03dce8dc3c2", + "gsd-core/references/questioning.md": "a8c988cab05f4651", + "gsd-core/references/research-documentation-lookup.md": "c070007d1d72ab71", + "gsd-core/references/research-philosophy.md": "62930e66cc979c1a", + "gsd-core/references/research-verification-protocol.md": "9c38c9d9a687e679", + "gsd-core/references/revision-loop.md": "e55ff32dd98c63df", + "gsd-core/references/scout-codebase.md": "ba266ecc18fbf172", + "gsd-core/references/security-asvs-levels.md": "4774fac3b94b6ca8", + "gsd-core/references/skeleton-template.md": "528691d1f0efa878", + "gsd-core/references/sketch-interactivity.md": "7d982fe877e1e1cc", + "gsd-core/references/sketch-theme-system.md": "33e2e96e450456f8", + "gsd-core/references/sketch-tooling.md": "df6c4f24c1c27611", + "gsd-core/references/sketch-variant-patterns.md": "66c197aa4fb52810", + "gsd-core/references/spidr-splitting.md": "074ac154c0e4f906", + "gsd-core/references/tdd.md": "e4708ede157478b6", + "gsd-core/references/thinking-models-debug.md": "2da61022b16c4e7c", + "gsd-core/references/thinking-models-execution.md": "dcc650a8b5f3e049", + "gsd-core/references/thinking-models-planning.md": "7e19462313fa028f", + "gsd-core/references/thinking-models-research.md": "5f6bf3f3b889c6e4", + "gsd-core/references/thinking-models-verification.md": "a71a933d51ca3d8d", + "gsd-core/references/thinking-partner.md": "827c1badf3e6df41", + "gsd-core/references/ui-brand.md": "48717bcfcd63bd27", + "gsd-core/references/universal-anti-patterns.md": "6a1245050b21df01", + "gsd-core/references/untrusted-input-boundary.md": "d33b80d4d348599a", + "gsd-core/references/user-profiling.md": "b50416fe57c1b321", + "gsd-core/references/user-story-template.md": "0cc50e06a144ff8a", + "gsd-core/references/verification-overrides.md": "a3e2d5166d16a37b", + "gsd-core/references/verification-patterns.md": "72f4d4814fdae9a8", + "gsd-core/references/verify-mvp-mode.md": "534bdc7f2432903a", + "gsd-core/references/workstream-flag.md": "ca99ca79e716f0f5", + "gsd-core/references/worktree-branch-check.md": "21d9c31bf6542b93", + "gsd-core/references/worktree-path-safety.md": "3c8d74756f9b16a8", + "gsd-core/templates/AI-SPEC.md": "24df5fe5ba34e367", + "gsd-core/templates/DEBUG.md": "57bd61bfd1d98e7e", + "gsd-core/templates/README.md": "90d2617778373147", + "gsd-core/templates/SECURITY.md": "b628f7f1c6d2328f", + "gsd-core/templates/UAT.md": "68d32d1fea14e184", + "gsd-core/templates/UI-SPEC.md": "20ca56a4e3e21f01", + "gsd-core/templates/VALIDATION.md": "6144951011cdca57", + "gsd-core/templates/claude-md.md": "d8f0fe8dba3bb28a", + "gsd-core/templates/codebase/architecture.md": "6be88214162fdd89", + "gsd-core/templates/codebase/concerns.md": "efa26d1fb5132f25", + "gsd-core/templates/codebase/conventions.md": "c2e07698dad6b364", + "gsd-core/templates/codebase/integrations.md": "39bd23c71eedd564", + "gsd-core/templates/codebase/stack.md": "116e7e67dd87ddec", + "gsd-core/templates/codebase/structure.md": "222997133232a6f5", + "gsd-core/templates/codebase/testing.md": "76abff7f2050c9ea", + "gsd-core/templates/config.json": "1473dff11272d3c4", + "gsd-core/templates/context.md": "69b01e7909ea3f66", + "gsd-core/templates/continue-here.md": "f522a51b6895fba8", + "gsd-core/templates/copilot-instructions.md": "aea34bc52ff548ea", + "gsd-core/templates/debug-subagent-prompt.md": "8c18a89e25929d8e", + "gsd-core/templates/dev-preferences.md": "95048a71063d980b", + "gsd-core/templates/discovery.md": "e4ab738326eb70e0", + "gsd-core/templates/discussion-log.md": "cac1b48ec0f4dcb8", + "gsd-core/templates/milestone-archive.md": "591b6decdc0c0e51", + "gsd-core/templates/milestone.md": "74d2f750ae9f4a9c", + "gsd-core/templates/phase-prompt.md": "213ccd947451ff2b", + "gsd-core/templates/planner-subagent-prompt.md": "6c9f1b23ee3dc05f", + "gsd-core/templates/project.md": "1f687c7a88381080", + "gsd-core/templates/requirements.md": "a44de4c2f146e473", + "gsd-core/templates/research-project/ARCHITECTURE.md": "746b9ef791d758b0", + "gsd-core/templates/research-project/FEATURES.md": "f2b800de5df91b0f", + "gsd-core/templates/research-project/PITFALLS.md": "3ef75fa768422eec", + "gsd-core/templates/research-project/STACK.md": "82c85799ac4dd344", + "gsd-core/templates/research-project/SUMMARY.md": "dceb2f346388839d", + "gsd-core/templates/research.md": "fa6dfb2ff2e8d273", + "gsd-core/templates/retrospective.md": "03981e30dd760103", + "gsd-core/templates/roadmap.md": "e4e35a9eb5dd4d4f", + "gsd-core/templates/spec.md": "7dc900c355098d8b", + "gsd-core/templates/state.md": "73e424b8c70b765c", + "gsd-core/templates/summary-complex.md": "a5e40574fd8894dc", + "gsd-core/templates/summary-minimal.md": "7d09b5e709e2e67c", + "gsd-core/templates/summary-standard.md": "e8d9cf4a8377cdff", + "gsd-core/templates/summary.md": "23c40f6503b3ea98", + "gsd-core/templates/user-profile.md": "20749f23e4c413fc", + "gsd-core/templates/user-setup.md": "78b7d718b6e8d67c", + "gsd-core/templates/verification-report.md": "dd5faa6254183731", + "gsd-core/workflows/_runtime-launcher.snippet.sh": "c0994e00f976dbfb", + "gsd-core/workflows/add-backlog.md": "5095b6e69b4e380a", + "gsd-core/workflows/add-phase.md": "27164a671e14a789", + "gsd-core/workflows/add-tests.md": "930998905facefc3", + "gsd-core/workflows/add-todo.md": "73934334ebbf5530", + "gsd-core/workflows/ai-integration-phase.md": "17017ec424e87b8d", + "gsd-core/workflows/analyze-dependencies.md": "77aff48f97fa6f1c", + "gsd-core/workflows/audit-fix.md": "eaead7ea85761e5b", + "gsd-core/workflows/audit-milestone.md": "5e9a35af57344e04", + "gsd-core/workflows/audit-uat.md": "69dbdec25cae2f12", + "gsd-core/workflows/autonomous.md": "c72e08a2afc08828", + "gsd-core/workflows/check-todos.md": "fa637b6cc9be647c", + "gsd-core/workflows/cleanup.md": "5cf0772521c192a4", + "gsd-core/workflows/code-review-fix.md": "a416c764425cbb61", + "gsd-core/workflows/code-review.md": "3999e5bc9ce9171e", + "gsd-core/workflows/complete-milestone.md": "6778d5f383d6f2f3", + "gsd-core/workflows/debug.md": "d5856f61f3a1ff84", + "gsd-core/workflows/diagnose-issues.md": "363684618298aecd", + "gsd-core/workflows/discovery-phase.md": "6161c60d752d0058", + "gsd-core/workflows/discuss-phase-assumptions.md": "af66efe23bd54b1d", + "gsd-core/workflows/discuss-phase-power.md": "290c0d83d783f9f6", + "gsd-core/workflows/discuss-phase.md": "6cbb63a540616e9d", + "gsd-core/workflows/discuss-phase/modes/advisor.md": "c7d4a3ff91655d24", + "gsd-core/workflows/discuss-phase/modes/all.md": "fa70d79066562e54", + "gsd-core/workflows/discuss-phase/modes/analyze.md": "da0788f3be7f8105", + "gsd-core/workflows/discuss-phase/modes/auto.md": "3333b280afd68321", + "gsd-core/workflows/discuss-phase/modes/batch.md": "6946597770e2d448", + "gsd-core/workflows/discuss-phase/modes/chain.md": "b16547a6b0423579", + "gsd-core/workflows/discuss-phase/modes/default.md": "67d1b67f61f03966", + "gsd-core/workflows/discuss-phase/modes/power.md": "fdc7a728eaaa2261", + "gsd-core/workflows/discuss-phase/modes/text.md": "b62c9085d4dc2963", + "gsd-core/workflows/discuss-phase/templates/checkpoint.json": "e3bc3dca49db59eb", + "gsd-core/workflows/discuss-phase/templates/context.md": "6cd929e989fe2b0f", + "gsd-core/workflows/discuss-phase/templates/discussion-log.md": "1bbd7703f11128e1", + "gsd-core/workflows/do.md": "032ea8e1d5a13d3b", + "gsd-core/workflows/docs-update.md": "98c30bec9350542f", + "gsd-core/workflows/edit-phase.md": "a3ab51739c5c021c", + "gsd-core/workflows/eval-review.md": "f47fd1a7a1ca3308", + "gsd-core/workflows/execute-phase.md": "9c16cc175f60b765", + "gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md": "c133828cf177a772", + "gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md": "7ebb7d1af6082028", + "gsd-core/workflows/execute-phase/steps/post-merge-gate.md": "abd2aca069c04a80", + "gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md": "be84efbd71e1513e", + "gsd-core/workflows/execute-plan.md": "8d77786e0842fefe", + "gsd-core/workflows/explore.md": "aee57d95497ca50a", + "gsd-core/workflows/extract-learnings.md": "ce4b5388074f19a3", + "gsd-core/workflows/fast.md": "54fe93778b45a7eb", + "gsd-core/workflows/forensics.md": "d0f079d1bcf924c3", + "gsd-core/workflows/graduation.md": "00d1b7fe7ed8578b", + "gsd-core/workflows/health.md": "9fd7fe8c7c99b0db", + "gsd-core/workflows/help.md": "5d040504b9ab35e3", + "gsd-core/workflows/help/modes/brief.md": "2e923b3ed635d495", + "gsd-core/workflows/help/modes/default.md": "b544113eb9aa54fa", + "gsd-core/workflows/help/modes/full.md": "b909d41bada51a6d", + "gsd-core/workflows/help/modes/topic.md": "bd2e4cc8e460f5bd", + "gsd-core/workflows/import.md": "b40557b17ca31024", + "gsd-core/workflows/inbox.md": "91aac6360e1a8672", + "gsd-core/workflows/ingest-docs.md": "a0a58450d457ae35", + "gsd-core/workflows/insert-phase.md": "0ab06e370253622b", + "gsd-core/workflows/list-phase-assumptions.md": "2a6b6a5acfb7742c", + "gsd-core/workflows/list-seeds.md": "da9511b569ada7f9", + "gsd-core/workflows/list-workspaces.md": "9b78bb9f71029169", + "gsd-core/workflows/manager.md": "c0b571445b452f2b", + "gsd-core/workflows/map-codebase.md": "27c356aa00fb022a", + "gsd-core/workflows/milestone-summary.md": "5bf68a980d8b5590", + "gsd-core/workflows/mvp-phase.md": "bfa668107be972ed", + "gsd-core/workflows/new-milestone.md": "74753bc968621017", + "gsd-core/workflows/new-project.md": "4184d47a8797dd4e", + "gsd-core/workflows/new-workspace.md": "668cd5038c76c771", + "gsd-core/workflows/next.md": "ef1b4c60ea3ddbc9", + "gsd-core/workflows/node-repair.md": "07a1628e5a1ff96b", + "gsd-core/workflows/note.md": "42b66686b2c102cb", + "gsd-core/workflows/pause-work.md": "0be71264eafd16dc", + "gsd-core/workflows/plan-milestone-gaps.md": "1976bf2001969719", + "gsd-core/workflows/plan-phase.md": "bce0904c3d3b7d59", + "gsd-core/workflows/plan-review-convergence.md": "414df04b7ddff73c", + "gsd-core/workflows/plant-seed.md": "936a848f1d6c409e", + "gsd-core/workflows/pr-branch.md": "c8827e8a15426bf5", + "gsd-core/workflows/profile-user.md": "4e17ead7b8335ad2", + "gsd-core/workflows/progress.md": "ff1627b59dcce0f9", + "gsd-core/workflows/quick.md": "992dea74dfdb39e7", + "gsd-core/workflows/reapply-patches.md": "2d7dada9edec108b", + "gsd-core/workflows/remove-phase.md": "f76e1c2a4dd31a09", + "gsd-core/workflows/remove-workspace.md": "0e73844f0f41cbc3", + "gsd-core/workflows/resume-project.md": "3dcaa7abe1800d35", + "gsd-core/workflows/review.md": "c32eac3c18b11691", + "gsd-core/workflows/scan.md": "8e1bbf2eed1752ca", + "gsd-core/workflows/secure-phase.md": "b2b9100ff79d6017", + "gsd-core/workflows/session-report.md": "2e5b1205324ddefa", + "gsd-core/workflows/settings-advanced.md": "df9a3b599fc74ba6", + "gsd-core/workflows/settings-integrations.md": "b503bf4784877aa9", + "gsd-core/workflows/settings.md": "3cbb449c1e02fe29", + "gsd-core/workflows/ship.md": "f8eece9ef821fc7c", + "gsd-core/workflows/sketch-wrap-up.md": "fcef0ef795f8bfc5", + "gsd-core/workflows/sketch.md": "dc9645b2ee28559d", + "gsd-core/workflows/spec-phase.md": "bab99d00772a5cd0", + "gsd-core/workflows/spike-wrap-up.md": "89a8d7fbc74ce8f2", + "gsd-core/workflows/spike.md": "aae8bcad15642645", + "gsd-core/workflows/stats.md": "17b4f2059f4b4ef2", + "gsd-core/workflows/sync-skills.md": "b505e6f8331c0918", + "gsd-core/workflows/thread.md": "75df5cc71f72c33d", + "gsd-core/workflows/transition.md": "96ce39403ca69594", + "gsd-core/workflows/ui-phase.md": "790e5982e5b715c3", + "gsd-core/workflows/ui-review.md": "51945fda8e931f99", + "gsd-core/workflows/ultraplan-phase.md": "92dbc48e9a7162aa", + "gsd-core/workflows/undo.md": "791e0bf96d9a057f", + "gsd-core/workflows/update.md": "0b389258dcc09332", + "gsd-core/workflows/validate-phase.md": "2aa540f2c2479501", + "gsd-core/workflows/verify-phase.md": "452968b6becb18a1", + "gsd-core/workflows/verify-work.md": "d2e8f5d5f2b8f050", + "hooks/gsd-check-update-worker.js": "f0c2b5b7169642ba", + "hooks/gsd-check-update.js": "a0e4882e66670e4d", + "hooks/gsd-config-reload.js": "17bf778d432b3d2a", + "hooks/gsd-context-monitor.js": "fbe88dd134dc7156", + "hooks/gsd-cursor-post-tool.js": "dd1b12f795de8d72", + "hooks/gsd-cursor-session-start.js": "a93095ac609a3ea6", + "hooks/gsd-ensure-canonical-path.js": "34f4522a23cc5f41", + "hooks/gsd-graphify-update.sh": "396ebda3c6705dc9", + "hooks/gsd-phase-boundary.sh": "6aa3ba9af3d465d9", + "hooks/gsd-prompt-guard.js": "4b08c2dce0233e2d", + "hooks/gsd-read-guard.js": "b602f88f046a7551", + "hooks/gsd-read-injection-scanner.js": "e149870bbd213882", + "hooks/gsd-session-state.sh": "b1496e6a5204a6df", + "hooks/gsd-statusline.js": "38cb2dd48cc03294", + "hooks/gsd-update-banner.js": "d3228b9e674296b4", + "hooks/gsd-validate-commit.sh": "14d3d966c74dc310", + "hooks/gsd-workflow-guard.js": "6faa8f81812a8b5d", + "hooks/gsd-worktree-path-guard.js": "5c2ebabb9d21b42a", + "hooks/lib/git-cmd.js": "268ba15992ca0b23", + "hooks/lib/gsd-graphify-rebuild.sh": "66af89601074d2a9", + "hooks/managed-hooks-registry.cjs": "ea876b1ec185173e", + "package.json": "dbf8353f77358bc1", + "scripts/changeset/README.md": "86ff89331dfd94b2", + "scripts/changeset/cli.cjs": "68f92a344b199271", + "scripts/changeset/github-release-notes.cjs": "795677f0c009b132", + "scripts/changeset/lint.cjs": "0066faed159154f0", + "scripts/changeset/new.cjs": "4991e21fd17f5541", + "scripts/changeset/parse.cjs": "f9a949cbcab56445", + "scripts/changeset/render.cjs": "e47bc3e1587c3cae", + "scripts/changeset/serialize.cjs": "ac0b8fe6f87cdb0e", + "scripts/fix-slash-commands.cjs": "0519742531ff3529", + "scripts/lib/allowlist-ratchet.cjs": "ffaceaac3efc2660", + "scripts/lib/cli-exit.cjs": "612d0c372c75b7e7", + "skills/gsd-add-tests/SKILL.md": "6661195a66f25ae8", + "skills/gsd-ai-integration-phase/SKILL.md": "96c8151779ad510e", + "skills/gsd-audit-fix/SKILL.md": "d47c757ad361e503", + "skills/gsd-audit-milestone/SKILL.md": "edb6a0cc6305d724", + "skills/gsd-audit-uat/SKILL.md": "f3cb11adb6dab54f", + "skills/gsd-autonomous/SKILL.md": "fb57d2c08ea9e8bc", + "skills/gsd-capture/SKILL.md": "05fa421c95fbad2d", + "skills/gsd-cleanup/SKILL.md": "ea0ddedf403f309d", + "skills/gsd-code-review/SKILL.md": "051da89e9f09932a", + "skills/gsd-complete-milestone/SKILL.md": "f6799a5a713be7bb", + "skills/gsd-config/SKILL.md": "f830f8c9887064de", + "skills/gsd-debug/SKILL.md": "42de44884d97d86d", + "skills/gsd-discuss-phase/SKILL.md": "99e764f6fb1f5dd4", + "skills/gsd-docs-update/SKILL.md": "3d41e81ec571de60", + "skills/gsd-eval-review/SKILL.md": "b6b6059061388363", + "skills/gsd-execute-phase/SKILL.md": "cd7d2b134924d57b", + "skills/gsd-explore/SKILL.md": "7ee5c455f37093be", + "skills/gsd-extract-learnings/SKILL.md": "8d38e55e5f8b774b", + "skills/gsd-fast/SKILL.md": "5f3f8b0c3f564d14", + "skills/gsd-forensics/SKILL.md": "4ef11f4cf902186f", + "skills/gsd-graphify/SKILL.md": "20804d74e63f594d", + "skills/gsd-health/SKILL.md": "ab21123ba99ec255", + "skills/gsd-help/SKILL.md": "503130b761263411", + "skills/gsd-import/SKILL.md": "e0c88bc1ceed0dd2", + "skills/gsd-inbox/SKILL.md": "d05a099c0be02c60", + "skills/gsd-ingest-docs/SKILL.md": "eb85e654917a503a", + "skills/gsd-manager/SKILL.md": "1370d93741e47828", + "skills/gsd-map-codebase/SKILL.md": "5009595dbde69739", + "skills/gsd-mempalace-capture/SKILL.md": "e9b66db79fce0a2f", + "skills/gsd-mempalace-recall/SKILL.md": "9bfa9e76c7a10e26", + "skills/gsd-milestone-summary/SKILL.md": "33d51a325d314f3a", + "skills/gsd-mvp-phase/SKILL.md": "f9a1348c6c297579", + "skills/gsd-new-milestone/SKILL.md": "ac99ffb8a966fe9d", + "skills/gsd-new-project/SKILL.md": "e4d930027074d3b6", + "skills/gsd-ns-context/SKILL.md": "3102e7ad9b60f182", + "skills/gsd-ns-ideate/SKILL.md": "c2c129408a046d22", + "skills/gsd-ns-manage/SKILL.md": "8d786e01fa28a7bb", + "skills/gsd-ns-project/SKILL.md": "cc0c4bd9feeab147", + "skills/gsd-ns-review/SKILL.md": "4111ea56e820479f", + "skills/gsd-ns-workflow/SKILL.md": "13c0d84b25545331", + "skills/gsd-pause-work/SKILL.md": "35e8a148e44f5361", + "skills/gsd-phase/SKILL.md": "00be96e7ae36c6f0", + "skills/gsd-plan-phase/SKILL.md": "0c9e87da048acfb7", + "skills/gsd-plan-review-convergence/SKILL.md": "c3dd8bfa877eaed5", + "skills/gsd-pr-branch/SKILL.md": "c5e26f2c6dff1355", + "skills/gsd-profile-user/SKILL.md": "894eb2850ecd2dde", + "skills/gsd-progress/SKILL.md": "9b288501db99c7ea", + "skills/gsd-quick/SKILL.md": "bd5e4cb79bc41611", + "skills/gsd-resume-work/SKILL.md": "e23d2fb963d47d04", + "skills/gsd-review-backlog/SKILL.md": "1708aab6cb919223", + "skills/gsd-review/SKILL.md": "b2fe23d7725c19f4", + "skills/gsd-secure-phase/SKILL.md": "a6adf4729b606d5a", + "skills/gsd-settings/SKILL.md": "6d9fbddb0b00fd46", + "skills/gsd-ship/SKILL.md": "9f7929947aac3c27", + "skills/gsd-sketch/SKILL.md": "e531174c131acc85", + "skills/gsd-spec-phase/SKILL.md": "7e4dfa2070b7d9d1", + "skills/gsd-spike/SKILL.md": "04d5f50d8d4a1c1a", + "skills/gsd-stats/SKILL.md": "5403a46852241fa8", + "skills/gsd-surface/SKILL.md": "970f30acc073a7b9", + "skills/gsd-thread/SKILL.md": "a76c70c368f82dee", + "skills/gsd-ui-phase/SKILL.md": "7c9404102a9b9d74", + "skills/gsd-ui-review/SKILL.md": "ef8f643abff1486b", + "skills/gsd-ultraplan-phase/SKILL.md": "a6c5aeacfa9bcbf1", + "skills/gsd-undo/SKILL.md": "0ad1218f55c94e4b", + "skills/gsd-update/SKILL.md": "25328e1ae7b51c98", + "skills/gsd-validate-phase/SKILL.md": "70375e2381319d2b", + "skills/gsd-verify-work/SKILL.md": "7ab3c2c1d008abd6", + "skills/gsd-workspace/SKILL.md": "047c3f4247bc869e", + "skills/gsd-workstreams/SKILL.md": "f4e54cb9b1ca0442" +} diff --git a/tests/fixtures/golden-install-parity/cline.json b/tests/fixtures/golden-install-parity/cline.json new file mode 100644 index 000000000..342457e30 --- /dev/null +++ b/tests/fixtures/golden-install-parity/cline.json @@ -0,0 +1,385 @@ +{ + ".agents/AGENTS.md": "4393766ace757e9d", + ".clinerules/gsd.md": "d72c089c31c1fef3", + ".clinerules/hooks/PreToolUse": "7271c83ab8a80911", + ".gsd-profile": "0e716a5fef4e6dc1", + ".gsd/defaults.json": "560664b045e645cb", + "agents/gsd-advisor-researcher.md": "7dfdadd98ec1cccc", + "agents/gsd-ai-researcher.md": "06e812e2f5bc8183", + "agents/gsd-assumptions-analyzer.md": "dc5e9973ff0740d7", + "agents/gsd-code-fixer.md": "b62fa87001d3cf5a", + "agents/gsd-code-reviewer.md": "f99a29f8982b8b51", + "agents/gsd-codebase-mapper.md": "3051a6f4183fe312", + "agents/gsd-debug-session-manager.md": "fc39ba45364ee4fd", + "agents/gsd-debugger.md": "fe69ba5bf548ccaa", + "agents/gsd-doc-classifier.md": "f7c361b91cc71a15", + "agents/gsd-doc-synthesizer.md": "f4e6ca19363e4b0f", + "agents/gsd-doc-verifier.md": "b60290c9d9f6d8d2", + "agents/gsd-doc-writer.md": "b0e9c568797e254a", + "agents/gsd-domain-researcher.md": "0fecdaea86466a56", + "agents/gsd-eval-auditor.md": "8d09ffc9c7659c5e", + "agents/gsd-eval-planner.md": "3ddea88a69b4da3f", + "agents/gsd-executor.md": "c6debb092b85f6fe", + "agents/gsd-framework-selector.md": "564669d479433f15", + "agents/gsd-integration-checker.md": "3d9a78f08e4782ff", + "agents/gsd-intel-updater.md": "b084fd8df5f13f2a", + "agents/gsd-mempalace-curator.md": "63676937f8970bf3", + "agents/gsd-nyquist-auditor.md": "80b109317fa4b60e", + "agents/gsd-pattern-mapper.md": "b526065fd2efa19c", + "agents/gsd-phase-researcher.md": "4c08ebba29e6e3f3", + "agents/gsd-plan-checker.md": "914d727ddc4cf8d9", + "agents/gsd-planner.md": "1011d56c5f9d1bf4", + "agents/gsd-project-researcher.md": "0028e4e6e66ad2b2", + "agents/gsd-research-synthesizer.md": "3bfa11826d592a32", + "agents/gsd-roadmapper.md": "2b86616f59bbeca2", + "agents/gsd-security-auditor.md": "297a35752df57ebd", + "agents/gsd-ui-auditor.md": "dc3af368e2f85abb", + "agents/gsd-ui-checker.md": "35a6b14813aa03ff", + "agents/gsd-ui-researcher.md": "878c7d0e82fa861a", + "agents/gsd-user-profiler.md": "622220df0654b6bf", + "agents/gsd-verifier.md": "64cc793b5f0110bc", + "gsd-core/CHANGELOG.md": "e141e3fb369ff712", + "gsd-core/VERSION": "562368b20a64be95", + "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", + "gsd-core/bin/gsd-tools.cjs": "b7968e3e3af00249", + "gsd-core/bin/gsd_run": "62d9b647ede212e6", + "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", + "gsd-core/bin/shared/config-schema.manifest.json": "67e4addbfd248a7c", + "gsd-core/bin/shared/model-catalog.json": "dbe26e683236d8c6", + "gsd-core/bin/shared/runtime-aliases.manifest.json": "f6c8b3af10dcfdc4", + "gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904", + "gsd-core/contexts/dev.md": "dcb0de9dce33cf41", + "gsd-core/contexts/research.md": "b3285d8e7209cc3b", + "gsd-core/contexts/review.md": "dc578fdd74bbea11", + "gsd-core/references/agent-contracts.md": "ff65e633c656c0d2", + "gsd-core/references/ai-evals.md": "b5afa786b938671e", + "gsd-core/references/ai-frameworks.md": "f827de93dde124eb", + "gsd-core/references/artifact-types.md": "a6d2e1f9453ffbf5", + "gsd-core/references/autonomous-smart-discuss.md": "2fc710cde0ec7785", + "gsd-core/references/checkpoints.md": "9a7ba3a17ece1698", + "gsd-core/references/common-bug-patterns.md": "780145be56352626", + "gsd-core/references/context-budget.md": "c154ba00dbbf4477", + "gsd-core/references/continuation-format.md": "ce73e57d053ab8a7", + "gsd-core/references/debugger-philosophy.md": "0466f95a3d6bfcd3", + "gsd-core/references/decimal-phase-calculation.md": "46b5ba045852c474", + "gsd-core/references/doc-conflict-engine.md": "67d019d23e17f934", + "gsd-core/references/domain-probes.md": "62d23ed1992c48a9", + "gsd-core/references/edge-probe-fixtures/01-round-half-even/expected-coverage.json": "72d1e29cedc854ec", + "gsd-core/references/edge-probe-fixtures/01-round-half-even/requirements.json": "fbc1b355d8625eeb", + "gsd-core/references/edge-probe-fixtures/02-merge-intervals/expected-coverage.json": "fad67dcc8294f6da", + "gsd-core/references/edge-probe-fixtures/02-merge-intervals/requirements.json": "30a78ee9ce3473ea", + "gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/expected-coverage.json": "66dd60957fee45f0", + "gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/requirements.json": "47fca61f076835fa", + "gsd-core/references/edge-probe-fixtures/04-money-rounding/expected-coverage.json": "72d1e29cedc854ec", + "gsd-core/references/edge-probe-fixtures/04-money-rounding/requirements.json": "80f04f5c04fb24cf", + "gsd-core/references/edge-probe-fixtures/05-list-dedupe/expected-coverage.json": "fad67dcc8294f6da", + "gsd-core/references/edge-probe-fixtures/05-list-dedupe/requirements.json": "d38147adb0e5b342", + "gsd-core/references/edge-probe-fixtures/06-resolved-mixed/expected-coverage.json": "bc552c01939bf4f8", + "gsd-core/references/edge-probe-fixtures/06-resolved-mixed/requirements.json": "30a78ee9ce3473ea", + "gsd-core/references/edge-probe-fixtures/06-resolved-mixed/resolutions.json": "688ec62c13e08afe", + "gsd-core/references/edge-probe.md": "5687eba25a078561", + "gsd-core/references/execute-mvp-tdd.md": "a98a270a7ab126bc", + "gsd-core/references/execute-phase-between-wave-reset.md": "e9582246dbc617e0", + "gsd-core/references/execute-phase-context-guard.md": "982006c1f3364242", + "gsd-core/references/execute-phase-wave-guard.md": "742bb890af014c87", + "gsd-core/references/executor-examples.md": "ba59243ed45c8ab1", + "gsd-core/references/few-shot-examples/plan-checker.md": "2574808188ac9de4", + "gsd-core/references/few-shot-examples/verifier.md": "5badee4560b14ae8", + "gsd-core/references/gate-prompts.md": "e69f5993ab944d80", + "gsd-core/references/gates.md": "7dc9fd3a3d6217c6", + "gsd-core/references/git-integration.md": "f5403e1470e46e69", + "gsd-core/references/git-planning-commit.md": "f897a15ebfc3f5a7", + "gsd-core/references/ios-scaffold.md": "5ef0cb7e0fac891f", + "gsd-core/references/loop-hook-dispatch.md": "32e5dfb4dba76987", + "gsd-core/references/mandatory-initial-read.md": "fe59abce693717cf", + "gsd-core/references/model-profile-resolution.md": "f32bb05102839767", + "gsd-core/references/model-profiles.md": "0941c3ab057c38a2", + "gsd-core/references/mvp-concepts.md": "72f7e8b1f8ae7118", + "gsd-core/references/phase-argument-parsing.md": "e5bbb985f3bc3e34", + "gsd-core/references/planner-antipatterns.md": "7ed54ec1e2cc54ac", + "gsd-core/references/planner-chunked.md": "79fe674221e738e6", + "gsd-core/references/planner-gap-closure.md": "76bee257911413e7", + "gsd-core/references/planner-graphify-auto-update.md": "6aeffd9097e25e22", + "gsd-core/references/planner-guidance.md": "96486cac2f7885e6", + "gsd-core/references/planner-human-verify-mode.md": "0519ef5438b2ed30", + "gsd-core/references/planner-interface-context.md": "b28fa3da6ae739a8", + "gsd-core/references/planner-load-graph-context.md": "e469016f2d51b5bc", + "gsd-core/references/planner-mvp-mode.md": "ec33050db81101a8", + "gsd-core/references/planner-reviews.md": "dda0193a0fbd4947", + "gsd-core/references/planner-revision.md": "86ba8a511f081f05", + "gsd-core/references/planner-source-audit.md": "7de5bdb07232ce0b", + "gsd-core/references/planning-config.md": "c386ac9e804f862e", + "gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json": "f10df472f2846cc6", + "gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json": "31e8a781eeffe020", + "gsd-core/references/prohibition-probe-fixtures/03-multi-prohibition/expected.json": "70a532a7cc1b6ae8", + "gsd-core/references/prohibition-probe.md": "605dc3f5a118ff3b", + "gsd-core/references/project-skills-discovery.md": "76b9d6526741fd0e", + "gsd-core/references/questioning.md": "a8c988cab05f4651", + "gsd-core/references/research-documentation-lookup.md": "c070007d1d72ab71", + "gsd-core/references/research-philosophy.md": "62930e66cc979c1a", + "gsd-core/references/research-verification-protocol.md": "9c38c9d9a687e679", + "gsd-core/references/revision-loop.md": "e55ff32dd98c63df", + "gsd-core/references/scout-codebase.md": "ba266ecc18fbf172", + "gsd-core/references/security-asvs-levels.md": "4774fac3b94b6ca8", + "gsd-core/references/skeleton-template.md": "528691d1f0efa878", + "gsd-core/references/sketch-interactivity.md": "7d982fe877e1e1cc", + "gsd-core/references/sketch-theme-system.md": "33e2e96e450456f8", + "gsd-core/references/sketch-tooling.md": "df6c4f24c1c27611", + "gsd-core/references/sketch-variant-patterns.md": "66c197aa4fb52810", + "gsd-core/references/spidr-splitting.md": "074ac154c0e4f906", + "gsd-core/references/tdd.md": "e4708ede157478b6", + "gsd-core/references/thinking-models-debug.md": "2da61022b16c4e7c", + "gsd-core/references/thinking-models-execution.md": "dcc650a8b5f3e049", + "gsd-core/references/thinking-models-planning.md": "7e19462313fa028f", + "gsd-core/references/thinking-models-research.md": "5f6bf3f3b889c6e4", + "gsd-core/references/thinking-models-verification.md": "a71a933d51ca3d8d", + "gsd-core/references/thinking-partner.md": "41069529ef776e39", + "gsd-core/references/ui-brand.md": "48717bcfcd63bd27", + "gsd-core/references/universal-anti-patterns.md": "6a1245050b21df01", + "gsd-core/references/untrusted-input-boundary.md": "d33b80d4d348599a", + "gsd-core/references/user-profiling.md": "b50416fe57c1b321", + "gsd-core/references/user-story-template.md": "0cc50e06a144ff8a", + "gsd-core/references/verification-overrides.md": "8213de9bd62283b6", + "gsd-core/references/verification-patterns.md": "72f4d4814fdae9a8", + "gsd-core/references/verify-mvp-mode.md": "534bdc7f2432903a", + "gsd-core/references/workstream-flag.md": "19340c1a3be74ad2", + "gsd-core/references/worktree-branch-check.md": "46882ad61f3f9075", + "gsd-core/references/worktree-path-safety.md": "8e5b4d542d2d9853", + "gsd-core/templates/AI-SPEC.md": "efa1f8354bd3a24b", + "gsd-core/templates/DEBUG.md": "a13470b82b1935e7", + "gsd-core/templates/README.md": "004c355d4b02b145", + "gsd-core/templates/SECURITY.md": "b628f7f1c6d2328f", + "gsd-core/templates/UAT.md": "9e296471b97ebcec", + "gsd-core/templates/UI-SPEC.md": "20ca56a4e3e21f01", + "gsd-core/templates/VALIDATION.md": "f53e0ca061d3528e", + "gsd-core/templates/claude-md.md": "c9fea2d8afa17d80", + "gsd-core/templates/codebase/architecture.md": "6be88214162fdd89", + "gsd-core/templates/codebase/concerns.md": "efa26d1fb5132f25", + "gsd-core/templates/codebase/conventions.md": "c2e07698dad6b364", + "gsd-core/templates/codebase/integrations.md": "39bd23c71eedd564", + "gsd-core/templates/codebase/stack.md": "116e7e67dd87ddec", + "gsd-core/templates/codebase/structure.md": "988307dc093216d1", + "gsd-core/templates/codebase/testing.md": "76abff7f2050c9ea", + "gsd-core/templates/config.json": "1473dff11272d3c4", + "gsd-core/templates/context.md": "69b01e7909ea3f66", + "gsd-core/templates/continue-here.md": "f522a51b6895fba8", + "gsd-core/templates/copilot-instructions.md": "aea34bc52ff548ea", + "gsd-core/templates/debug-subagent-prompt.md": "920656683dedb869", + "gsd-core/templates/dev-preferences.md": "88d0a65ec0993a3a", + "gsd-core/templates/discovery.md": "9a0e0935cc825dbc", + "gsd-core/templates/discussion-log.md": "cac1b48ec0f4dcb8", + "gsd-core/templates/milestone-archive.md": "591b6decdc0c0e51", + "gsd-core/templates/milestone.md": "74d2f750ae9f4a9c", + "gsd-core/templates/phase-prompt.md": "b811bf951092df2f", + "gsd-core/templates/planner-subagent-prompt.md": "ebf29dbb27042370", + "gsd-core/templates/project.md": "ab4af6becca2bb23", + "gsd-core/templates/requirements.md": "a44de4c2f146e473", + "gsd-core/templates/research-project/ARCHITECTURE.md": "746b9ef791d758b0", + "gsd-core/templates/research-project/FEATURES.md": "f2b800de5df91b0f", + "gsd-core/templates/research-project/PITFALLS.md": "3ef75fa768422eec", + "gsd-core/templates/research-project/STACK.md": "82c85799ac4dd344", + "gsd-core/templates/research-project/SUMMARY.md": "dceb2f346388839d", + "gsd-core/templates/research.md": "88ce0920417091d0", + "gsd-core/templates/retrospective.md": "03981e30dd760103", + "gsd-core/templates/roadmap.md": "e4e35a9eb5dd4d4f", + "gsd-core/templates/spec.md": "26d55bce940f0288", + "gsd-core/templates/state.md": "4d123aa6cea167fe", + "gsd-core/templates/summary-complex.md": "a5e40574fd8894dc", + "gsd-core/templates/summary-minimal.md": "7d09b5e709e2e67c", + "gsd-core/templates/summary-standard.md": "e8d9cf4a8377cdff", + "gsd-core/templates/summary.md": "23c40f6503b3ea98", + "gsd-core/templates/user-profile.md": "20749f23e4c413fc", + "gsd-core/templates/user-setup.md": "78b7d718b6e8d67c", + "gsd-core/templates/verification-report.md": "dd5faa6254183731", + "gsd-core/workflows/_runtime-launcher.snippet.sh": "c0994e00f976dbfb", + "gsd-core/workflows/add-backlog.md": "805471417ad921b4", + "gsd-core/workflows/add-phase.md": "38a6397d692b6256", + "gsd-core/workflows/add-tests.md": "3182b4a48b60fc0f", + "gsd-core/workflows/add-todo.md": "2ee36f06763af56b", + "gsd-core/workflows/ai-integration-phase.md": "e1216f51ba0662f2", + "gsd-core/workflows/analyze-dependencies.md": "52942af10f140717", + "gsd-core/workflows/audit-fix.md": "85a83f15012e220d", + "gsd-core/workflows/audit-milestone.md": "7e68a0d33382fbca", + "gsd-core/workflows/audit-uat.md": "2ab2975014fbb210", + "gsd-core/workflows/autonomous.md": "40a72366442139c2", + "gsd-core/workflows/check-todos.md": "ab6239efbf42077b", + "gsd-core/workflows/cleanup.md": "869f953fb25e57a7", + "gsd-core/workflows/code-review-fix.md": "40d723ec1c6b45d1", + "gsd-core/workflows/code-review.md": "4aee640e6e6951ce", + "gsd-core/workflows/complete-milestone.md": "c3a489ca30f8bccb", + "gsd-core/workflows/debug.md": "1d138ad68a5f94dd", + "gsd-core/workflows/diagnose-issues.md": "ed74414bee6a146f", + "gsd-core/workflows/discovery-phase.md": "3ba7cfb89fb1e761", + "gsd-core/workflows/discuss-phase-assumptions.md": "dbb54ab5455a1e49", + "gsd-core/workflows/discuss-phase-power.md": "0841f7dc6e9a054a", + "gsd-core/workflows/discuss-phase.md": "17ef5303bf8c61b2", + "gsd-core/workflows/discuss-phase/modes/advisor.md": "711bdeb87a76fe72", + "gsd-core/workflows/discuss-phase/modes/all.md": "fa70d79066562e54", + "gsd-core/workflows/discuss-phase/modes/analyze.md": "da0788f3be7f8105", + "gsd-core/workflows/discuss-phase/modes/auto.md": "a677457cfcf26929", + "gsd-core/workflows/discuss-phase/modes/batch.md": "6946597770e2d448", + "gsd-core/workflows/discuss-phase/modes/chain.md": "d7f857f6d916084a", + "gsd-core/workflows/discuss-phase/modes/default.md": "67d1b67f61f03966", + "gsd-core/workflows/discuss-phase/modes/power.md": "fdc7a728eaaa2261", + "gsd-core/workflows/discuss-phase/modes/text.md": "e38583ffc6743580", + "gsd-core/workflows/discuss-phase/templates/checkpoint.json": "e3bc3dca49db59eb", + "gsd-core/workflows/discuss-phase/templates/context.md": "6cd929e989fe2b0f", + "gsd-core/workflows/discuss-phase/templates/discussion-log.md": "1bbd7703f11128e1", + "gsd-core/workflows/do.md": "491de71927bca5a0", + "gsd-core/workflows/docs-update.md": "4aee7d852ab26710", + "gsd-core/workflows/edit-phase.md": "db501e21c762d2b2", + "gsd-core/workflows/eval-review.md": "e211cca4eea94930", + "gsd-core/workflows/execute-phase.md": "dae9e35eb0ee41fc", + "gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md": "84b49fd65d290399", + "gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md": "7ebb7d1af6082028", + "gsd-core/workflows/execute-phase/steps/post-merge-gate.md": "bcb4ab75df626846", + "gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md": "be84efbd71e1513e", + "gsd-core/workflows/execute-plan.md": "81458a19cf02a5bc", + "gsd-core/workflows/explore.md": "28856a6b0198064b", + "gsd-core/workflows/extract-learnings.md": "b649ff8ecff388c1", + "gsd-core/workflows/fast.md": "94136fb570d20a9d", + "gsd-core/workflows/forensics.md": "2478dc912608126f", + "gsd-core/workflows/graduation.md": "ca7fa951a963d84b", + "gsd-core/workflows/health.md": "295e170806820a94", + "gsd-core/workflows/help.md": "5d040504b9ab35e3", + "gsd-core/workflows/help/modes/brief.md": "5fe077fbbdb5d9f7", + "gsd-core/workflows/help/modes/default.md": "77c34f4a77dec02c", + "gsd-core/workflows/help/modes/full.md": "6924f9cf169175e0", + "gsd-core/workflows/help/modes/topic.md": "d7c60d488810fbd6", + "gsd-core/workflows/import.md": "64c8031374b39376", + "gsd-core/workflows/inbox.md": "437f981ef9ae7b26", + "gsd-core/workflows/ingest-docs.md": "62761071f8391015", + "gsd-core/workflows/insert-phase.md": "37fb13804b4e61f2", + "gsd-core/workflows/list-phase-assumptions.md": "53dd4b69536c1bc3", + "gsd-core/workflows/list-seeds.md": "d3b2783b53d96746", + "gsd-core/workflows/list-workspaces.md": "e2d7ee5e70395c1d", + "gsd-core/workflows/manager.md": "3ae982361d3cbb06", + "gsd-core/workflows/map-codebase.md": "893f13a40162ce23", + "gsd-core/workflows/milestone-summary.md": "dd87dcdecb0b069d", + "gsd-core/workflows/mvp-phase.md": "e6a902c3308345f2", + "gsd-core/workflows/new-milestone.md": "ff7a75c9d7e863eb", + "gsd-core/workflows/new-project.md": "f5799451fb7c1983", + "gsd-core/workflows/new-workspace.md": "277c868dc684ad05", + "gsd-core/workflows/next.md": "e64c75922be6d8e2", + "gsd-core/workflows/node-repair.md": "07a1628e5a1ff96b", + "gsd-core/workflows/note.md": "5a99eb396c744619", + "gsd-core/workflows/pause-work.md": "9c1acf8c30a244fd", + "gsd-core/workflows/plan-milestone-gaps.md": "95ca791b0867fe2d", + "gsd-core/workflows/plan-phase.md": "2716d6636e37ce6a", + "gsd-core/workflows/plan-review-convergence.md": "2cdba6216184aac4", + "gsd-core/workflows/plant-seed.md": "d0d63f83ae7c939b", + "gsd-core/workflows/pr-branch.md": "15ccec6bd303ea46", + "gsd-core/workflows/profile-user.md": "21955dc3f74c66df", + "gsd-core/workflows/progress.md": "ec78c08395cc39f5", + "gsd-core/workflows/quick.md": "4b763c75164e0f1d", + "gsd-core/workflows/reapply-patches.md": "4cfdb59f5d8e727e", + "gsd-core/workflows/remove-phase.md": "61b68a4af414e3c2", + "gsd-core/workflows/remove-workspace.md": "411bfff942216185", + "gsd-core/workflows/resume-project.md": "f8f71b5a98374b85", + "gsd-core/workflows/review.md": "897a4c72a97178f8", + "gsd-core/workflows/scan.md": "db0c48c3963f9a8b", + "gsd-core/workflows/secure-phase.md": "c51b86e369574195", + "gsd-core/workflows/session-report.md": "2e5b1205324ddefa", + "gsd-core/workflows/settings-advanced.md": "29a70ffc83bfc4b4", + "gsd-core/workflows/settings-integrations.md": "7e9fbe941882c4f6", + "gsd-core/workflows/settings.md": "bc0e1b43249e32b0", + "gsd-core/workflows/ship.md": "c3db35c8d1b398c1", + "gsd-core/workflows/sketch-wrap-up.md": "91f40fc816293e3c", + "gsd-core/workflows/sketch.md": "92a8bab2605469fb", + "gsd-core/workflows/spec-phase.md": "e06346c9c626a2d2", + "gsd-core/workflows/spike-wrap-up.md": "693949a4e10e66bd", + "gsd-core/workflows/spike.md": "204e742c846ee0d9", + "gsd-core/workflows/stats.md": "5cfea82b894eee3c", + "gsd-core/workflows/sync-skills.md": "b505e6f8331c0918", + "gsd-core/workflows/thread.md": "5ae4c3141bdedd88", + "gsd-core/workflows/transition.md": "fe82e77df8dceb1b", + "gsd-core/workflows/ui-phase.md": "06f1f80de620a319", + "gsd-core/workflows/ui-review.md": "0af83311f5e41f48", + "gsd-core/workflows/ultraplan-phase.md": "b4e64685b40bce09", + "gsd-core/workflows/undo.md": "96d2775f008b3a85", + "gsd-core/workflows/update.md": "9cad8a8f4baff922", + "gsd-core/workflows/validate-phase.md": "54687a0f2a562fc4", + "gsd-core/workflows/verify-phase.md": "68a74f247fdce962", + "gsd-core/workflows/verify-work.md": "6f482d32e0c8d49a", + "scripts/changeset/README.md": "86ff89331dfd94b2", + "scripts/changeset/cli.cjs": "68f92a344b199271", + "scripts/changeset/github-release-notes.cjs": "795677f0c009b132", + "scripts/changeset/lint.cjs": "0066faed159154f0", + "scripts/changeset/new.cjs": "4991e21fd17f5541", + "scripts/changeset/parse.cjs": "f9a949cbcab56445", + "scripts/changeset/render.cjs": "e47bc3e1587c3cae", + "scripts/changeset/serialize.cjs": "ac0b8fe6f87cdb0e", + "scripts/fix-slash-commands.cjs": "0519742531ff3529", + "scripts/lib/allowlist-ratchet.cjs": "ffaceaac3efc2660", + "scripts/lib/cli-exit.cjs": "612d0c372c75b7e7", + "skills/gsd-ns-context/SKILL.md": "e278a50f3ecb8f56", + "skills/gsd-ns-context/skills/docs-update/SKILL.md": "d150afd41a25ec08", + "skills/gsd-ns-context/skills/extract-learnings/SKILL.md": "d39269bea995fabc", + "skills/gsd-ns-context/skills/graphify/SKILL.md": "030050fcd08d129d", + "skills/gsd-ns-context/skills/map-codebase/SKILL.md": "19aec854da15f77b", + "skills/gsd-ns-context/skills/mempalace-capture/SKILL.md": "5647f915e5c85482", + "skills/gsd-ns-context/skills/mempalace-recall/SKILL.md": "d41523e659d1920f", + "skills/gsd-ns-ideate/SKILL.md": "c10342345c01c91f", + "skills/gsd-ns-ideate/skills/capture/SKILL.md": "fb53053848bb6695", + "skills/gsd-ns-ideate/skills/explore/SKILL.md": "d8e26a5ed95a4ecc", + "skills/gsd-ns-ideate/skills/sketch/SKILL.md": "e52cfc46eeb203c2", + "skills/gsd-ns-ideate/skills/spec-phase/SKILL.md": "19726b83ca2c3d2d", + "skills/gsd-ns-ideate/skills/spike/SKILL.md": "d407644a84678e58", + "skills/gsd-ns-manage/SKILL.md": "0f4fb6d2f96ed7e9", + "skills/gsd-ns-manage/skills/cleanup/SKILL.md": "a578d6536b9cabc3", + "skills/gsd-ns-manage/skills/config/SKILL.md": "bcc58a5b17d29e82", + "skills/gsd-ns-manage/skills/health/SKILL.md": "74a68e1c1b310bef", + "skills/gsd-ns-manage/skills/help/SKILL.md": "8d01c91265b6357f", + "skills/gsd-ns-manage/skills/inbox/SKILL.md": "34370c6138415209", + "skills/gsd-ns-manage/skills/manager/SKILL.md": "a37ecdbe32952262", + "skills/gsd-ns-manage/skills/pause-work/SKILL.md": "da59910491639404", + "skills/gsd-ns-manage/skills/pr-branch/SKILL.md": "c25b5a3e31ab6f74", + "skills/gsd-ns-manage/skills/resume-work/SKILL.md": "5f0d4d0e86b99180", + "skills/gsd-ns-manage/skills/settings/SKILL.md": "67eff2d16e17403c", + "skills/gsd-ns-manage/skills/ship/SKILL.md": "4ad9695934e069ee", + "skills/gsd-ns-manage/skills/stats/SKILL.md": "25898070fb2a4b20", + "skills/gsd-ns-manage/skills/surface/SKILL.md": "6d6ed15c90424f3f", + "skills/gsd-ns-manage/skills/thread/SKILL.md": "67b5a0451b58c50c", + "skills/gsd-ns-manage/skills/undo/SKILL.md": "88407c6379617c7b", + "skills/gsd-ns-manage/skills/update/SKILL.md": "2666ab7786b69017", + "skills/gsd-ns-manage/skills/workspace/SKILL.md": "c2616b157ec2350b", + "skills/gsd-ns-manage/skills/workstreams/SKILL.md": "737841783c7fdd4f", + "skills/gsd-ns-project/SKILL.md": "5668e3a5e1d8896d", + "skills/gsd-ns-project/skills/audit-milestone/SKILL.md": "739ed755ad84e58e", + "skills/gsd-ns-project/skills/complete-milestone/SKILL.md": "a69d534b385d6aaf", + "skills/gsd-ns-project/skills/import/SKILL.md": "bc9b615cb141d26f", + "skills/gsd-ns-project/skills/ingest-docs/SKILL.md": "8fce43ae1144b8c3", + "skills/gsd-ns-project/skills/milestone-summary/SKILL.md": "e3636a5e9bbaab1f", + "skills/gsd-ns-project/skills/new-milestone/SKILL.md": "bc8989d97f3161ad", + "skills/gsd-ns-project/skills/new-project/SKILL.md": "ff0d57491df30624", + "skills/gsd-ns-project/skills/profile-user/SKILL.md": "0406eed4dbd9560c", + "skills/gsd-ns-project/skills/review-backlog/SKILL.md": "d8a150558cc9326a", + "skills/gsd-ns-review/SKILL.md": "c5afe33c212947dd", + "skills/gsd-ns-review/skills/audit-fix/SKILL.md": "2c1f601f2fb52585", + "skills/gsd-ns-review/skills/audit-uat/SKILL.md": "2c450432b6fd1c3e", + "skills/gsd-ns-review/skills/code-review/SKILL.md": "2d615701a36de114", + "skills/gsd-ns-review/skills/debug/SKILL.md": "2017ee8a5c39357a", + "skills/gsd-ns-review/skills/eval-review/SKILL.md": "c76022e94ee30cb1", + "skills/gsd-ns-review/skills/forensics/SKILL.md": "bbab359eaa388d34", + "skills/gsd-ns-review/skills/review/SKILL.md": "04c7f2a8515d97a2", + "skills/gsd-ns-review/skills/secure-phase/SKILL.md": "ceded474164b4e2d", + "skills/gsd-ns-review/skills/ui-phase/SKILL.md": "c35f175ccc747d56", + "skills/gsd-ns-review/skills/ui-review/SKILL.md": "48267dc071481a89", + "skills/gsd-ns-review/skills/validate-phase/SKILL.md": "62dc6ae62bb38c16", + "skills/gsd-ns-workflow/SKILL.md": "7dbd699b5130c0d5", + "skills/gsd-ns-workflow/skills/add-tests/SKILL.md": "addc3a220961a9c7", + "skills/gsd-ns-workflow/skills/ai-integration-phase/SKILL.md": "e74b83a991dc9fc7", + "skills/gsd-ns-workflow/skills/autonomous/SKILL.md": "37dc7b78ceb74803", + "skills/gsd-ns-workflow/skills/discuss-phase/SKILL.md": "fedb2e2c77ff82ae", + "skills/gsd-ns-workflow/skills/execute-phase/SKILL.md": "e249a35959e49e99", + "skills/gsd-ns-workflow/skills/fast/SKILL.md": "3eae6536d09c2532", + "skills/gsd-ns-workflow/skills/mvp-phase/SKILL.md": "6c2beccceb46fda1", + "skills/gsd-ns-workflow/skills/phase/SKILL.md": "4e1363db6013a1e5", + "skills/gsd-ns-workflow/skills/plan-phase/SKILL.md": "b7a6ff2837b41143", + "skills/gsd-ns-workflow/skills/plan-review-convergence/SKILL.md": "b7d213288df9aa96", + "skills/gsd-ns-workflow/skills/progress/SKILL.md": "493f467c22d55b6b", + "skills/gsd-ns-workflow/skills/quick/SKILL.md": "605e596c680cbb1c", + "skills/gsd-ns-workflow/skills/spec-phase/SKILL.md": "19726b83ca2c3d2d", + "skills/gsd-ns-workflow/skills/ultraplan-phase/SKILL.md": "8606e89121ffaba3", + "skills/gsd-ns-workflow/skills/verify-work/SKILL.md": "584a3485842de0a4" +} diff --git a/tests/fixtures/golden-install-parity/codebuddy.json b/tests/fixtures/golden-install-parity/codebuddy.json new file mode 100644 index 000000000..4adbda5e2 --- /dev/null +++ b/tests/fixtures/golden-install-parity/codebuddy.json @@ -0,0 +1,472 @@ +{ + ".gsd-profile": "0e716a5fef4e6dc1", + ".gsd/defaults.json": "560664b045e645cb", + "agents/gsd-advisor-researcher.md": "7dfdadd98ec1cccc", + "agents/gsd-ai-researcher.md": "a98e6ce03a3f7565", + "agents/gsd-assumptions-analyzer.md": "dc5e9973ff0740d7", + "agents/gsd-code-fixer.md": "645c70ed0155f7c4", + "agents/gsd-code-reviewer.md": "372137acc4155b5f", + "agents/gsd-codebase-mapper.md": "7e2293c462389be3", + "agents/gsd-debug-session-manager.md": "d7c0e160cbf76c68", + "agents/gsd-debugger.md": "f1936c442c12bf30", + "agents/gsd-doc-classifier.md": "095e804a82f4a47f", + "agents/gsd-doc-synthesizer.md": "2a78fe239d00389c", + "agents/gsd-doc-verifier.md": "e74d930905a782ea", + "agents/gsd-doc-writer.md": "9a9e66be3981411a", + "agents/gsd-domain-researcher.md": "1c1a800108a2b225", + "agents/gsd-eval-auditor.md": "380ed8b15f07a680", + "agents/gsd-eval-planner.md": "4ebdd7fe9cbb0cfe", + "agents/gsd-executor.md": "11e5d8ae52c08196", + "agents/gsd-framework-selector.md": "7726fccc86bfeb50", + "agents/gsd-integration-checker.md": "239c6bbd19c6895b", + "agents/gsd-intel-updater.md": "ec317a56d4abc4d3", + "agents/gsd-mempalace-curator.md": "63676937f8970bf3", + "agents/gsd-nyquist-auditor.md": "50cee88d38c797a3", + "agents/gsd-pattern-mapper.md": "92cfa2e6c2a06bf3", + "agents/gsd-phase-researcher.md": "3d1aa51cbea008e7", + "agents/gsd-plan-checker.md": "f747da9c3126c918", + "agents/gsd-planner.md": "04a0281878973985", + "agents/gsd-project-researcher.md": "78294b9bb8bbbd42", + "agents/gsd-research-synthesizer.md": "d75fb540f0e01f08", + "agents/gsd-roadmapper.md": "956125a1f6774aae", + "agents/gsd-security-auditor.md": "6401d8f6f966ac2d", + "agents/gsd-ui-auditor.md": "a2730bb158f93cd4", + "agents/gsd-ui-checker.md": "68ee3eb209c9f0d6", + "agents/gsd-ui-researcher.md": "507ed07fc9ba7d33", + "agents/gsd-user-profiler.md": "622220df0654b6bf", + "agents/gsd-verifier.md": "b62b7966b4aafd5b", + "commands/gsd-add-tests.md": "aa65032141557fb7", + "commands/gsd-ai-integration-phase.md": "373053d7cd887aa9", + "commands/gsd-audit-fix.md": "c21ef925131fade7", + "commands/gsd-audit-milestone.md": "accbb975f85df506", + "commands/gsd-audit-uat.md": "f34f9d211780c8e7", + "commands/gsd-autonomous.md": "b2ddf4f71008055a", + "commands/gsd-capture.md": "73066649385d6a30", + "commands/gsd-cleanup.md": "15c306114555468e", + "commands/gsd-code-review.md": "fb1fee97d4ce7aa7", + "commands/gsd-complete-milestone.md": "42b9f738a6ef27bb", + "commands/gsd-config.md": "d2ca72138eb3e185", + "commands/gsd-debug.md": "279b3814e35e57e1", + "commands/gsd-discuss-phase.md": "b0e66df97b3d35b4", + "commands/gsd-docs-update.md": "0bd04559e7daaf8f", + "commands/gsd-eval-review.md": "5c0bf98aee70535f", + "commands/gsd-execute-phase.md": "4c2e81399876492a", + "commands/gsd-explore.md": "8a59d0d0ea3daebd", + "commands/gsd-extract-learnings.md": "727feb914d501f1a", + "commands/gsd-fast.md": "79bf42c66008a6db", + "commands/gsd-forensics.md": "f068866c568749a4", + "commands/gsd-graphify.md": "5e0efaed49aa8384", + "commands/gsd-health.md": "60c111036afc00d2", + "commands/gsd-help.md": "1a617e1b8e383b73", + "commands/gsd-import.md": "00e560b7e4b85523", + "commands/gsd-inbox.md": "eecfcfff57f89975", + "commands/gsd-ingest-docs.md": "ab3e58239c23d61d", + "commands/gsd-manager.md": "bfe9e261fa918876", + "commands/gsd-map-codebase.md": "9ed09a9a9ce48ffd", + "commands/gsd-mempalace-capture.md": "6fa6ca7a1ecd562d", + "commands/gsd-mempalace-recall.md": "146d97ab543d8df2", + "commands/gsd-milestone-summary.md": "e3a36fbd695a1833", + "commands/gsd-mvp-phase.md": "671791ba61c4654a", + "commands/gsd-new-milestone.md": "00b5d757991941c7", + "commands/gsd-new-project.md": "490195d5003d3d17", + "commands/gsd-ns-context.md": "cc8954f405ae7916", + "commands/gsd-ns-ideate.md": "fd1dd80d705e6e36", + "commands/gsd-ns-manage.md": "5cc012d0be1caabb", + "commands/gsd-ns-project.md": "52e30c8d84bb1ee5", + "commands/gsd-ns-review.md": "a28dbfd4aba5f74b", + "commands/gsd-ns-workflow.md": "dcd214fc50008d42", + "commands/gsd-pause-work.md": "6caa75a7c2b4dd2d", + "commands/gsd-phase.md": "e3ca4958ea20a935", + "commands/gsd-plan-phase.md": "6de27d54ac191539", + "commands/gsd-plan-review-convergence.md": "4d1b90383514958e", + "commands/gsd-pr-branch.md": "f5be514b9f69eaf5", + "commands/gsd-profile-user.md": "7a9289910719d828", + "commands/gsd-progress.md": "80f75f428fc0949d", + "commands/gsd-quick.md": "8585535a111660d1", + "commands/gsd-resume-work.md": "9c4ee571a15fefae", + "commands/gsd-review-backlog.md": "41ee0337839b8e9d", + "commands/gsd-review.md": "4d1b7eaef1560fb4", + "commands/gsd-secure-phase.md": "6daf0c899069ab74", + "commands/gsd-settings.md": "57edae813aae049c", + "commands/gsd-ship.md": "9190bbab8119a365", + "commands/gsd-sketch.md": "4013cb35a4a800be", + "commands/gsd-spec-phase.md": "145f2ab750344022", + "commands/gsd-spike.md": "54c094f40b601688", + "commands/gsd-stats.md": "60f7077e7949d2ad", + "commands/gsd-surface.md": "29688da5df785b58", + "commands/gsd-thread.md": "07da4f657b3efcc1", + "commands/gsd-ui-phase.md": "3b90f3d1c8fa531d", + "commands/gsd-ui-review.md": "4e5fb1e77def9b64", + "commands/gsd-ultraplan-phase.md": "728a2e57e10e6179", + "commands/gsd-undo.md": "a52c89b888e6c1b7", + "commands/gsd-update.md": "58bc4232c21a74ba", + "commands/gsd-validate-phase.md": "a735abb7db023543", + "commands/gsd-verify-work.md": "6edbbb82a1f2aea7", + "commands/gsd-workspace.md": "d765ff60cde657a6", + "commands/gsd-workstreams.md": "884b6c8d648422c7", + "gsd-core/CHANGELOG.md": "e141e3fb369ff712", + "gsd-core/VERSION": "562368b20a64be95", + "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", + "gsd-core/bin/gsd-tools.cjs": "b7968e3e3af00249", + "gsd-core/bin/gsd_run": "62d9b647ede212e6", + "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", + "gsd-core/bin/shared/config-schema.manifest.json": "67e4addbfd248a7c", + "gsd-core/bin/shared/model-catalog.json": "dbe26e683236d8c6", + "gsd-core/bin/shared/runtime-aliases.manifest.json": "f6c8b3af10dcfdc4", + "gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904", + "gsd-core/contexts/dev.md": "dcb0de9dce33cf41", + "gsd-core/contexts/research.md": "b3285d8e7209cc3b", + "gsd-core/contexts/review.md": "dc578fdd74bbea11", + "gsd-core/references/agent-contracts.md": "ff65e633c656c0d2", + "gsd-core/references/ai-evals.md": "b5afa786b938671e", + "gsd-core/references/ai-frameworks.md": "f827de93dde124eb", + "gsd-core/references/artifact-types.md": "a6d2e1f9453ffbf5", + "gsd-core/references/autonomous-smart-discuss.md": "2fc710cde0ec7785", + "gsd-core/references/checkpoints.md": "6aa620c6ca38bdf0", + "gsd-core/references/common-bug-patterns.md": "780145be56352626", + "gsd-core/references/context-budget.md": "f1ce57bf418824af", + "gsd-core/references/continuation-format.md": "ce73e57d053ab8a7", + "gsd-core/references/debugger-philosophy.md": "0466f95a3d6bfcd3", + "gsd-core/references/decimal-phase-calculation.md": "46b5ba045852c474", + "gsd-core/references/doc-conflict-engine.md": "67d019d23e17f934", + "gsd-core/references/domain-probes.md": "62d23ed1992c48a9", + "gsd-core/references/edge-probe-fixtures/01-round-half-even/expected-coverage.json": "72d1e29cedc854ec", + "gsd-core/references/edge-probe-fixtures/01-round-half-even/requirements.json": "fbc1b355d8625eeb", + "gsd-core/references/edge-probe-fixtures/02-merge-intervals/expected-coverage.json": "fad67dcc8294f6da", + "gsd-core/references/edge-probe-fixtures/02-merge-intervals/requirements.json": "30a78ee9ce3473ea", + "gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/expected-coverage.json": "66dd60957fee45f0", + "gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/requirements.json": "47fca61f076835fa", + "gsd-core/references/edge-probe-fixtures/04-money-rounding/expected-coverage.json": "72d1e29cedc854ec", + "gsd-core/references/edge-probe-fixtures/04-money-rounding/requirements.json": "80f04f5c04fb24cf", + "gsd-core/references/edge-probe-fixtures/05-list-dedupe/expected-coverage.json": "fad67dcc8294f6da", + "gsd-core/references/edge-probe-fixtures/05-list-dedupe/requirements.json": "d38147adb0e5b342", + "gsd-core/references/edge-probe-fixtures/06-resolved-mixed/expected-coverage.json": "bc552c01939bf4f8", + "gsd-core/references/edge-probe-fixtures/06-resolved-mixed/requirements.json": "30a78ee9ce3473ea", + "gsd-core/references/edge-probe-fixtures/06-resolved-mixed/resolutions.json": "688ec62c13e08afe", + "gsd-core/references/edge-probe.md": "5687eba25a078561", + "gsd-core/references/execute-mvp-tdd.md": "a98a270a7ab126bc", + "gsd-core/references/execute-phase-between-wave-reset.md": "3ad96ca0f7fee37e", + "gsd-core/references/execute-phase-context-guard.md": "982006c1f3364242", + "gsd-core/references/execute-phase-wave-guard.md": "de9ac22cead4cfd8", + "gsd-core/references/executor-examples.md": "ba59243ed45c8ab1", + "gsd-core/references/few-shot-examples/plan-checker.md": "2574808188ac9de4", + "gsd-core/references/few-shot-examples/verifier.md": "5badee4560b14ae8", + "gsd-core/references/gate-prompts.md": "e69f5993ab944d80", + "gsd-core/references/gates.md": "7dc9fd3a3d6217c6", + "gsd-core/references/git-integration.md": "77bf9dff38b2c9d4", + "gsd-core/references/git-planning-commit.md": "f897a15ebfc3f5a7", + "gsd-core/references/ios-scaffold.md": "5ef0cb7e0fac891f", + "gsd-core/references/loop-hook-dispatch.md": "32e5dfb4dba76987", + "gsd-core/references/mandatory-initial-read.md": "fe59abce693717cf", + "gsd-core/references/model-profile-resolution.md": "f32bb05102839767", + "gsd-core/references/model-profiles.md": "d3513252c5de5465", + "gsd-core/references/mvp-concepts.md": "72f7e8b1f8ae7118", + "gsd-core/references/phase-argument-parsing.md": "e5bbb985f3bc3e34", + "gsd-core/references/planner-antipatterns.md": "7ed54ec1e2cc54ac", + "gsd-core/references/planner-chunked.md": "79fe674221e738e6", + "gsd-core/references/planner-gap-closure.md": "76bee257911413e7", + "gsd-core/references/planner-graphify-auto-update.md": "6aeffd9097e25e22", + "gsd-core/references/planner-guidance.md": "96486cac2f7885e6", + "gsd-core/references/planner-human-verify-mode.md": "56d05e841630b3f4", + "gsd-core/references/planner-interface-context.md": "b28fa3da6ae739a8", + "gsd-core/references/planner-load-graph-context.md": "ca7a7af3f35ae61b", + "gsd-core/references/planner-mvp-mode.md": "ec33050db81101a8", + "gsd-core/references/planner-reviews.md": "dda0193a0fbd4947", + "gsd-core/references/planner-revision.md": "86ba8a511f081f05", + "gsd-core/references/planner-source-audit.md": "7de5bdb07232ce0b", + "gsd-core/references/planning-config.md": "1c3a3aae2ae89e83", + "gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json": "f10df472f2846cc6", + "gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json": "31e8a781eeffe020", + "gsd-core/references/prohibition-probe-fixtures/03-multi-prohibition/expected.json": "70a532a7cc1b6ae8", + "gsd-core/references/prohibition-probe.md": "605dc3f5a118ff3b", + "gsd-core/references/project-skills-discovery.md": "c155e03dce8dc3c2", + "gsd-core/references/questioning.md": "a8c988cab05f4651", + "gsd-core/references/research-documentation-lookup.md": "c070007d1d72ab71", + "gsd-core/references/research-philosophy.md": "62930e66cc979c1a", + "gsd-core/references/research-verification-protocol.md": "9c38c9d9a687e679", + "gsd-core/references/revision-loop.md": "e55ff32dd98c63df", + "gsd-core/references/scout-codebase.md": "ba266ecc18fbf172", + "gsd-core/references/security-asvs-levels.md": "4774fac3b94b6ca8", + "gsd-core/references/skeleton-template.md": "528691d1f0efa878", + "gsd-core/references/sketch-interactivity.md": "7d982fe877e1e1cc", + "gsd-core/references/sketch-theme-system.md": "33e2e96e450456f8", + "gsd-core/references/sketch-tooling.md": "df6c4f24c1c27611", + "gsd-core/references/sketch-variant-patterns.md": "66c197aa4fb52810", + "gsd-core/references/spidr-splitting.md": "074ac154c0e4f906", + "gsd-core/references/tdd.md": "e4708ede157478b6", + "gsd-core/references/thinking-models-debug.md": "2da61022b16c4e7c", + "gsd-core/references/thinking-models-execution.md": "dcc650a8b5f3e049", + "gsd-core/references/thinking-models-planning.md": "7e19462313fa028f", + "gsd-core/references/thinking-models-research.md": "5f6bf3f3b889c6e4", + "gsd-core/references/thinking-models-verification.md": "a71a933d51ca3d8d", + "gsd-core/references/thinking-partner.md": "41069529ef776e39", + "gsd-core/references/ui-brand.md": "48717bcfcd63bd27", + "gsd-core/references/universal-anti-patterns.md": "6a1245050b21df01", + "gsd-core/references/untrusted-input-boundary.md": "d33b80d4d348599a", + "gsd-core/references/user-profiling.md": "b50416fe57c1b321", + "gsd-core/references/user-story-template.md": "0cc50e06a144ff8a", + "gsd-core/references/verification-overrides.md": "8213de9bd62283b6", + "gsd-core/references/verification-patterns.md": "72f4d4814fdae9a8", + "gsd-core/references/verify-mvp-mode.md": "534bdc7f2432903a", + "gsd-core/references/workstream-flag.md": "ca99ca79e716f0f5", + "gsd-core/references/worktree-branch-check.md": "21d9c31bf6542b93", + "gsd-core/references/worktree-path-safety.md": "3c8d74756f9b16a8", + "gsd-core/templates/AI-SPEC.md": "efa1f8354bd3a24b", + "gsd-core/templates/DEBUG.md": "a13470b82b1935e7", + "gsd-core/templates/README.md": "93d3426fc64e2c12", + "gsd-core/templates/SECURITY.md": "b628f7f1c6d2328f", + "gsd-core/templates/UAT.md": "9e296471b97ebcec", + "gsd-core/templates/UI-SPEC.md": "20ca56a4e3e21f01", + "gsd-core/templates/VALIDATION.md": "f53e0ca061d3528e", + "gsd-core/templates/claude-md.md": "d1d333e4b963c0d2", + "gsd-core/templates/codebase/architecture.md": "6be88214162fdd89", + "gsd-core/templates/codebase/concerns.md": "efa26d1fb5132f25", + "gsd-core/templates/codebase/conventions.md": "c2e07698dad6b364", + "gsd-core/templates/codebase/integrations.md": "39bd23c71eedd564", + "gsd-core/templates/codebase/stack.md": "116e7e67dd87ddec", + "gsd-core/templates/codebase/structure.md": "222997133232a6f5", + "gsd-core/templates/codebase/testing.md": "76abff7f2050c9ea", + "gsd-core/templates/config.json": "1473dff11272d3c4", + "gsd-core/templates/context.md": "69b01e7909ea3f66", + "gsd-core/templates/continue-here.md": "f522a51b6895fba8", + "gsd-core/templates/copilot-instructions.md": "aea34bc52ff548ea", + "gsd-core/templates/debug-subagent-prompt.md": "920656683dedb869", + "gsd-core/templates/dev-preferences.md": "88d0a65ec0993a3a", + "gsd-core/templates/discovery.md": "9a0e0935cc825dbc", + "gsd-core/templates/discussion-log.md": "cac1b48ec0f4dcb8", + "gsd-core/templates/milestone-archive.md": "591b6decdc0c0e51", + "gsd-core/templates/milestone.md": "74d2f750ae9f4a9c", + "gsd-core/templates/phase-prompt.md": "b811bf951092df2f", + "gsd-core/templates/planner-subagent-prompt.md": "ebf29dbb27042370", + "gsd-core/templates/project.md": "ab4af6becca2bb23", + "gsd-core/templates/requirements.md": "a44de4c2f146e473", + "gsd-core/templates/research-project/ARCHITECTURE.md": "746b9ef791d758b0", + "gsd-core/templates/research-project/FEATURES.md": "f2b800de5df91b0f", + "gsd-core/templates/research-project/PITFALLS.md": "3ef75fa768422eec", + "gsd-core/templates/research-project/STACK.md": "82c85799ac4dd344", + "gsd-core/templates/research-project/SUMMARY.md": "dceb2f346388839d", + "gsd-core/templates/research.md": "88ce0920417091d0", + "gsd-core/templates/retrospective.md": "03981e30dd760103", + "gsd-core/templates/roadmap.md": "e4e35a9eb5dd4d4f", + "gsd-core/templates/spec.md": "26d55bce940f0288", + "gsd-core/templates/state.md": "4d123aa6cea167fe", + "gsd-core/templates/summary-complex.md": "a5e40574fd8894dc", + "gsd-core/templates/summary-minimal.md": "7d09b5e709e2e67c", + "gsd-core/templates/summary-standard.md": "e8d9cf4a8377cdff", + "gsd-core/templates/summary.md": "23c40f6503b3ea98", + "gsd-core/templates/user-profile.md": "20749f23e4c413fc", + "gsd-core/templates/user-setup.md": "78b7d718b6e8d67c", + "gsd-core/templates/verification-report.md": "dd5faa6254183731", + "gsd-core/workflows/_runtime-launcher.snippet.sh": "c0994e00f976dbfb", + "gsd-core/workflows/add-backlog.md": "1153d35058e3e24f", + "gsd-core/workflows/add-phase.md": "b98c2fc51cd123ba", + "gsd-core/workflows/add-tests.md": "2871f7e6409ba89e", + "gsd-core/workflows/add-todo.md": "3b913840c1f490b2", + "gsd-core/workflows/ai-integration-phase.md": "ad2ff20091d1c2c0", + "gsd-core/workflows/analyze-dependencies.md": "52942af10f140717", + "gsd-core/workflows/audit-fix.md": "eaead7ea85761e5b", + "gsd-core/workflows/audit-milestone.md": "fe7185bb70eafdb9", + "gsd-core/workflows/audit-uat.md": "ca6f1f0ef174f793", + "gsd-core/workflows/autonomous.md": "ba6828223ba13c1f", + "gsd-core/workflows/check-todos.md": "1e3026abe782bd36", + "gsd-core/workflows/cleanup.md": "5cf0772521c192a4", + "gsd-core/workflows/code-review-fix.md": "b442c670f8c1fe93", + "gsd-core/workflows/code-review.md": "1bfa3bf542e30c5e", + "gsd-core/workflows/complete-milestone.md": "8a4cfb41a4de5a99", + "gsd-core/workflows/debug.md": "da62e7a62c113b29", + "gsd-core/workflows/diagnose-issues.md": "690c523df07f2ea7", + "gsd-core/workflows/discovery-phase.md": "3ba7cfb89fb1e761", + "gsd-core/workflows/discuss-phase-assumptions.md": "a365fdca7f7281ad", + "gsd-core/workflows/discuss-phase-power.md": "0841f7dc6e9a054a", + "gsd-core/workflows/discuss-phase.md": "a0b7539c8b3c25cd", + "gsd-core/workflows/discuss-phase/modes/advisor.md": "c7d4a3ff91655d24", + "gsd-core/workflows/discuss-phase/modes/all.md": "fa70d79066562e54", + "gsd-core/workflows/discuss-phase/modes/analyze.md": "da0788f3be7f8105", + "gsd-core/workflows/discuss-phase/modes/auto.md": "3333b280afd68321", + "gsd-core/workflows/discuss-phase/modes/batch.md": "6946597770e2d448", + "gsd-core/workflows/discuss-phase/modes/chain.md": "ca3cc1ddfd19cd9d", + "gsd-core/workflows/discuss-phase/modes/default.md": "67d1b67f61f03966", + "gsd-core/workflows/discuss-phase/modes/power.md": "fdc7a728eaaa2261", + "gsd-core/workflows/discuss-phase/modes/text.md": "da6d45207da4a988", + "gsd-core/workflows/discuss-phase/templates/checkpoint.json": "e3bc3dca49db59eb", + "gsd-core/workflows/discuss-phase/templates/context.md": "6cd929e989fe2b0f", + "gsd-core/workflows/discuss-phase/templates/discussion-log.md": "1bbd7703f11128e1", + "gsd-core/workflows/do.md": "152e9c5c512f79cb", + "gsd-core/workflows/docs-update.md": "806ada831b961116", + "gsd-core/workflows/edit-phase.md": "e5624ac6e3f8bef5", + "gsd-core/workflows/eval-review.md": "dfcfb4f8ce031fae", + "gsd-core/workflows/execute-phase.md": "8e2e773435d5cae9", + "gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md": "8898e0ea533cc643", + "gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md": "7ebb7d1af6082028", + "gsd-core/workflows/execute-phase/steps/post-merge-gate.md": "abd2aca069c04a80", + "gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md": "be84efbd71e1513e", + "gsd-core/workflows/execute-plan.md": "89fc525aecb29aad", + "gsd-core/workflows/explore.md": "11523b829cc67c29", + "gsd-core/workflows/extract-learnings.md": "a2e4836abdeb1fdb", + "gsd-core/workflows/fast.md": "94136fb570d20a9d", + "gsd-core/workflows/forensics.md": "76bea6e254652c2c", + "gsd-core/workflows/graduation.md": "00d1b7fe7ed8578b", + "gsd-core/workflows/health.md": "e3cb8ff4e01e9e53", + "gsd-core/workflows/help.md": "5d040504b9ab35e3", + "gsd-core/workflows/help/modes/brief.md": "5fe077fbbdb5d9f7", + "gsd-core/workflows/help/modes/default.md": "cc8ef97de634c995", + "gsd-core/workflows/help/modes/full.md": "1e95876d570df64b", + "gsd-core/workflows/help/modes/topic.md": "d7c60d488810fbd6", + "gsd-core/workflows/import.md": "97861d522319d56e", + "gsd-core/workflows/inbox.md": "437f981ef9ae7b26", + "gsd-core/workflows/ingest-docs.md": "77d9103d8d5432f3", + "gsd-core/workflows/insert-phase.md": "621f9e75725542b1", + "gsd-core/workflows/list-phase-assumptions.md": "53dd4b69536c1bc3", + "gsd-core/workflows/list-seeds.md": "0a466d04df300854", + "gsd-core/workflows/list-workspaces.md": "6e5c402000090d7b", + "gsd-core/workflows/manager.md": "a31f7e255dd7d01d", + "gsd-core/workflows/map-codebase.md": "582bddef590de9c6", + "gsd-core/workflows/milestone-summary.md": "f956783f7d063963", + "gsd-core/workflows/mvp-phase.md": "bfa668107be972ed", + "gsd-core/workflows/new-milestone.md": "6d07ca2fc7aec3a4", + "gsd-core/workflows/new-project.md": "2fa5ddc0fe749b28", + "gsd-core/workflows/new-workspace.md": "fdd63a9adf030cb1", + "gsd-core/workflows/next.md": "48bd4b5d54f0d1f8", + "gsd-core/workflows/node-repair.md": "07a1628e5a1ff96b", + "gsd-core/workflows/note.md": "5a99eb396c744619", + "gsd-core/workflows/pause-work.md": "7bcbdf27ba957c8b", + "gsd-core/workflows/plan-milestone-gaps.md": "02fee851c82e3b25", + "gsd-core/workflows/plan-phase.md": "3b30ccd918b5f703", + "gsd-core/workflows/plan-review-convergence.md": "10007f8382864bcd", + "gsd-core/workflows/plant-seed.md": "7b795d7a1b4c9f06", + "gsd-core/workflows/pr-branch.md": "f2a35833fe784a53", + "gsd-core/workflows/profile-user.md": "fd15f5d92ac1703b", + "gsd-core/workflows/progress.md": "057699b34b6198f7", + "gsd-core/workflows/quick.md": "4d5a30b67a1ec703", + "gsd-core/workflows/reapply-patches.md": "7915e40411490bbe", + "gsd-core/workflows/remove-phase.md": "d030f80ca0df4fa9", + "gsd-core/workflows/remove-workspace.md": "b8817a3a5907f5bc", + "gsd-core/workflows/resume-project.md": "b18b51fd15cbce95", + "gsd-core/workflows/review.md": "18ab093986814bb3", + "gsd-core/workflows/scan.md": "54ff1ff60041d065", + "gsd-core/workflows/secure-phase.md": "1d1c66ad9ea01bd2", + "gsd-core/workflows/session-report.md": "2e5b1205324ddefa", + "gsd-core/workflows/settings-advanced.md": "e7e50f99e4de3872", + "gsd-core/workflows/settings-integrations.md": "351c5a6d0d5e27ed", + "gsd-core/workflows/settings.md": "48d337eb7e3f141b", + "gsd-core/workflows/ship.md": "d26fb5d3e965642a", + "gsd-core/workflows/sketch-wrap-up.md": "7d52aa95ee69d47a", + "gsd-core/workflows/sketch.md": "55cee885b4add548", + "gsd-core/workflows/spec-phase.md": "2aa0353147153776", + "gsd-core/workflows/spike-wrap-up.md": "2fde13092ba1af57", + "gsd-core/workflows/spike.md": "0051a7e2193a7522", + "gsd-core/workflows/stats.md": "01c24349370a0e6d", + "gsd-core/workflows/sync-skills.md": "b505e6f8331c0918", + "gsd-core/workflows/thread.md": "c26ca43fdf928d46", + "gsd-core/workflows/transition.md": "eee3435817fab185", + "gsd-core/workflows/ui-phase.md": "93ea0d0dfcb2a1e8", + "gsd-core/workflows/ui-review.md": "1ad3654435000881", + "gsd-core/workflows/ultraplan-phase.md": "66c17ad2e555e262", + "gsd-core/workflows/undo.md": "96d2775f008b3a85", + "gsd-core/workflows/update.md": "6a593863d1b0a287", + "gsd-core/workflows/validate-phase.md": "50f37b705b6e44fb", + "gsd-core/workflows/verify-phase.md": "968d569ea4ef377f", + "gsd-core/workflows/verify-work.md": "6f9c666386cb6d7e", + "hooks/gsd-check-update-worker.js": "084c3f7109bb4d29", + "hooks/gsd-check-update.js": "23074675b7c31a47", + "hooks/gsd-config-reload.js": "17bf778d432b3d2a", + "hooks/gsd-context-monitor.js": "3b47e76273f1a440", + "hooks/gsd-cursor-post-tool.js": "d61ee04f6ee7858c", + "hooks/gsd-cursor-session-start.js": "148b8ec4e2c97f00", + "hooks/gsd-ensure-canonical-path.js": "6900c226c1c28a44", + "hooks/gsd-graphify-update.sh": "396ebda3c6705dc9", + "hooks/gsd-phase-boundary.sh": "6aa3ba9af3d465d9", + "hooks/gsd-prompt-guard.js": "4b08c2dce0233e2d", + "hooks/gsd-read-guard.js": "b602f88f046a7551", + "hooks/gsd-read-injection-scanner.js": "95fe2c59ef5bba35", + "hooks/gsd-session-state.sh": "b1496e6a5204a6df", + "hooks/gsd-statusline.js": "b3549ec6d96337b3", + "hooks/gsd-update-banner.js": "74817c820b7a4ec1", + "hooks/gsd-validate-commit.sh": "14d3d966c74dc310", + "hooks/gsd-workflow-guard.js": "e22b9fb57f0e64f6", + "hooks/gsd-worktree-path-guard.js": "f3ca67ad040431a7", + "hooks/lib/git-cmd.js": "268ba15992ca0b23", + "hooks/lib/gsd-graphify-rebuild.sh": "66af89601074d2a9", + "hooks/managed-hooks-registry.cjs": "0368fd4ac7bb3d1d", + "package.json": "dbf8353f77358bc1", + "scripts/changeset/README.md": "86ff89331dfd94b2", + "scripts/changeset/cli.cjs": "68f92a344b199271", + "scripts/changeset/github-release-notes.cjs": "795677f0c009b132", + "scripts/changeset/lint.cjs": "0066faed159154f0", + "scripts/changeset/new.cjs": "4991e21fd17f5541", + "scripts/changeset/parse.cjs": "f9a949cbcab56445", + "scripts/changeset/render.cjs": "e47bc3e1587c3cae", + "scripts/changeset/serialize.cjs": "ac0b8fe6f87cdb0e", + "scripts/fix-slash-commands.cjs": "0519742531ff3529", + "scripts/lib/allowlist-ratchet.cjs": "ffaceaac3efc2660", + "scripts/lib/cli-exit.cjs": "612d0c372c75b7e7", + "skills/gsd-add-tests/SKILL.md": "60dd7f9779d5f493", + "skills/gsd-ai-integration-phase/SKILL.md": "686ba35b47442cf4", + "skills/gsd-audit-fix/SKILL.md": "309dcbc406750fd6", + "skills/gsd-audit-milestone/SKILL.md": "1f328f0c7448f27c", + "skills/gsd-audit-uat/SKILL.md": "597d4f8cd0fa5240", + "skills/gsd-autonomous/SKILL.md": "faf243b21954b407", + "skills/gsd-capture/SKILL.md": "6de5ad4b9ea3b599", + "skills/gsd-cleanup/SKILL.md": "fa47be0052de815b", + "skills/gsd-code-review/SKILL.md": "86a9df56bbd7ac7b", + "skills/gsd-complete-milestone/SKILL.md": "a0a9e43312160286", + "skills/gsd-config/SKILL.md": "862626bacdeb9e75", + "skills/gsd-debug/SKILL.md": "ecd7e69309a6e6dc", + "skills/gsd-discuss-phase/SKILL.md": "75fec0c03892505a", + "skills/gsd-docs-update/SKILL.md": "bec33cfc810194d9", + "skills/gsd-eval-review/SKILL.md": "65e26f5ec8ff57cc", + "skills/gsd-execute-phase/SKILL.md": "40e479d06dbd8da4", + "skills/gsd-explore/SKILL.md": "fc2d0fd090e7090d", + "skills/gsd-extract-learnings/SKILL.md": "778faa10f3c33788", + "skills/gsd-fast/SKILL.md": "6c81b52c310d43fa", + "skills/gsd-forensics/SKILL.md": "6c69cbd255d3a33a", + "skills/gsd-graphify/SKILL.md": "579180d7ea6e24cf", + "skills/gsd-health/SKILL.md": "e60400c5acb8248c", + "skills/gsd-help/SKILL.md": "98a2eeba302621d2", + "skills/gsd-import/SKILL.md": "f3d3ad26dfdfbd53", + "skills/gsd-inbox/SKILL.md": "0eb81a1b2e1835d3", + "skills/gsd-ingest-docs/SKILL.md": "2df55e0ec95d57d9", + "skills/gsd-manager/SKILL.md": "816db4d4ed618537", + "skills/gsd-map-codebase/SKILL.md": "00622b489483ba69", + "skills/gsd-mempalace-capture/SKILL.md": "12a57bef275e2961", + "skills/gsd-mempalace-recall/SKILL.md": "69275abbee414350", + "skills/gsd-milestone-summary/SKILL.md": "9eb030a1ac307fa1", + "skills/gsd-mvp-phase/SKILL.md": "236fcdd69f47d101", + "skills/gsd-new-milestone/SKILL.md": "be371ad30631385e", + "skills/gsd-new-project/SKILL.md": "793f7177d3bd026b", + "skills/gsd-ns-context/SKILL.md": "305b9b6248e8cc95", + "skills/gsd-ns-ideate/SKILL.md": "c63a1aed61888b35", + "skills/gsd-ns-manage/SKILL.md": "6a5b711b5103c566", + "skills/gsd-ns-project/SKILL.md": "345ea11ccffd21b0", + "skills/gsd-ns-review/SKILL.md": "98f1f3b3caf1a279", + "skills/gsd-ns-workflow/SKILL.md": "eda03f7d99eb2f8e", + "skills/gsd-pause-work/SKILL.md": "e2de20b7539e78d4", + "skills/gsd-phase/SKILL.md": "f87211f779315ced", + "skills/gsd-plan-phase/SKILL.md": "6134e837750034ab", + "skills/gsd-plan-review-convergence/SKILL.md": "e76d306ba8883cdb", + "skills/gsd-pr-branch/SKILL.md": "87bb3306488565fb", + "skills/gsd-profile-user/SKILL.md": "ee8e8547298006b8", + "skills/gsd-progress/SKILL.md": "88c8229b673004e3", + "skills/gsd-quick/SKILL.md": "b895cb9835f3fc0e", + "skills/gsd-resume-work/SKILL.md": "f8bc8b92dffd07b7", + "skills/gsd-review-backlog/SKILL.md": "c0c743e1376c2eff", + "skills/gsd-review/SKILL.md": "7f8c2adde7ec94ba", + "skills/gsd-secure-phase/SKILL.md": "bd36a3157c28df37", + "skills/gsd-settings/SKILL.md": "fbe736decd04a85d", + "skills/gsd-ship/SKILL.md": "8d40fc9f9436d2c3", + "skills/gsd-sketch/SKILL.md": "0686257acc865e0b", + "skills/gsd-spec-phase/SKILL.md": "96a095cd634129d8", + "skills/gsd-spike/SKILL.md": "d4af42b801139876", + "skills/gsd-stats/SKILL.md": "7d1fbadcae5f0b67", + "skills/gsd-surface/SKILL.md": "5008f49701e7deea", + "skills/gsd-thread/SKILL.md": "18ddd2aa60997949", + "skills/gsd-ui-phase/SKILL.md": "132ccf2ddc9e24e0", + "skills/gsd-ui-review/SKILL.md": "7e8cf0bcee9859cd", + "skills/gsd-ultraplan-phase/SKILL.md": "212a60a2df7bbb71", + "skills/gsd-undo/SKILL.md": "ebaca8bea34afd2e", + "skills/gsd-update/SKILL.md": "fd60247c80431aa9", + "skills/gsd-validate-phase/SKILL.md": "5216ef11d14f109b", + "skills/gsd-verify-work/SKILL.md": "d467197419fdbdf5", + "skills/gsd-workspace/SKILL.md": "7fcacf69b4c07655", + "skills/gsd-workstreams/SKILL.md": "b84ca1f541d6dfd1" +} diff --git a/tests/fixtures/golden-install-parity/codex.json b/tests/fixtures/golden-install-parity/codex.json new file mode 100644 index 000000000..6cadb8d5a --- /dev/null +++ b/tests/fixtures/golden-install-parity/codex.json @@ -0,0 +1,418 @@ +{ + ".gsd-profile": "0e716a5fef4e6dc1", + ".gsd/defaults.json": "560664b045e645cb", + "agents/gsd-advisor-researcher.md": "08c87f08c10f9fe8", + "agents/gsd-advisor-researcher.toml": "fff34ae5730d3925", + "agents/gsd-ai-researcher.md": "dd2a0f7b77950345", + "agents/gsd-ai-researcher.toml": "d4c65198a7f33b12", + "agents/gsd-assumptions-analyzer.md": "90b8d8021543a3d2", + "agents/gsd-assumptions-analyzer.toml": "058cfdb3f9bd0ef2", + "agents/gsd-code-fixer.md": "3d41cafa29f1288d", + "agents/gsd-code-fixer.toml": "7e5727b2950d8b66", + "agents/gsd-code-reviewer.md": "ecd9b3a7a4f6de8e", + "agents/gsd-code-reviewer.toml": "bf69f37605310411", + "agents/gsd-codebase-mapper.md": "4c7b89630170e50c", + "agents/gsd-codebase-mapper.toml": "aad6b8ef6039aca7", + "agents/gsd-debug-session-manager.md": "a4433f8cac0ae81c", + "agents/gsd-debug-session-manager.toml": "8b45a8660607e1ae", + "agents/gsd-debugger.md": "3b039fd7673e02d3", + "agents/gsd-debugger.toml": "1a0752ab636ae3b2", + "agents/gsd-doc-classifier.md": "0ee2bcb95b247ef8", + "agents/gsd-doc-classifier.toml": "8b94b5c8b02c133a", + "agents/gsd-doc-synthesizer.md": "2a3fbfce6cf5e671", + "agents/gsd-doc-synthesizer.toml": "823930fa132ce21b", + "agents/gsd-doc-verifier.md": "42d07e55a2e571ce", + "agents/gsd-doc-verifier.toml": "c0b7ac150730b954", + "agents/gsd-doc-writer.md": "04f7c8d02df44244", + "agents/gsd-doc-writer.toml": "07e6ee2d0c308a7c", + "agents/gsd-domain-researcher.md": "bafef7d698ccb928", + "agents/gsd-domain-researcher.toml": "a49ddec9005b4bf5", + "agents/gsd-eval-auditor.md": "e3073afb63f67657", + "agents/gsd-eval-auditor.toml": "e67cf6a252b7bcd3", + "agents/gsd-eval-planner.md": "73f2ad2ff2797a51", + "agents/gsd-eval-planner.toml": "09468ad1a34ac468", + "agents/gsd-executor.md": "1eb8527fd57aad12", + "agents/gsd-executor.toml": "d18b3f8577d3bbbc", + "agents/gsd-framework-selector.md": "ebae32430887d2e0", + "agents/gsd-framework-selector.toml": "637e4e021b7ec380", + "agents/gsd-integration-checker.md": "6670fb75b8c87ff7", + "agents/gsd-integration-checker.toml": "233a924946673207", + "agents/gsd-intel-updater.md": "097c1a4b90b28ba0", + "agents/gsd-intel-updater.toml": "07f8098bd5e0e65e", + "agents/gsd-mempalace-curator.md": "159f24243bf3acfb", + "agents/gsd-mempalace-curator.toml": "30bcaf1ca0b926db", + "agents/gsd-nyquist-auditor.md": "54160990e73c205d", + "agents/gsd-nyquist-auditor.toml": "fd27a7a7c5e79f50", + "agents/gsd-pattern-mapper.md": "efbcfa7c5de4027f", + "agents/gsd-pattern-mapper.toml": "48a17baa3d7d142c", + "agents/gsd-phase-researcher.md": "b86a0a50667adb08", + "agents/gsd-phase-researcher.toml": "c6e22db2fc92789a", + "agents/gsd-plan-checker.md": "41056ff7b3009633", + "agents/gsd-plan-checker.toml": "aa452ed8c4ae585f", + "agents/gsd-planner.md": "975d25062341bb78", + "agents/gsd-planner.toml": "d1436204f6ed4701", + "agents/gsd-project-researcher.md": "06ae50c62d4b826e", + "agents/gsd-project-researcher.toml": "10effe5cac601065", + "agents/gsd-research-synthesizer.md": "befa4887698e4aae", + "agents/gsd-research-synthesizer.toml": "4b3e626d3c16094f", + "agents/gsd-roadmapper.md": "cb10c3a3dc2340ad", + "agents/gsd-roadmapper.toml": "a9765ce5fa9e8121", + "agents/gsd-security-auditor.md": "91a3d5c406e49965", + "agents/gsd-security-auditor.toml": "61b0622381966c0e", + "agents/gsd-ui-auditor.md": "d59452c406406811", + "agents/gsd-ui-auditor.toml": "4979cd66a16e3f2b", + "agents/gsd-ui-checker.md": "5d11e1c7cf9b539e", + "agents/gsd-ui-checker.toml": "ec6b8919fd153ae3", + "agents/gsd-ui-researcher.md": "129a5f325546260f", + "agents/gsd-ui-researcher.toml": "ffe2ca0b232df3df", + "agents/gsd-user-profiler.md": "1bf5033c929181c1", + "agents/gsd-user-profiler.toml": "b9c244bb8fbf8140", + "agents/gsd-verifier.md": "994e2a4f22bc3c8b", + "agents/gsd-verifier.toml": "9743a551e04bc0a3", + "config.toml": "a34316b9ac61a620", + "gsd-core/CHANGELOG.md": "e141e3fb369ff712", + "gsd-core/VERSION": "562368b20a64be95", + "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", + "gsd-core/bin/gsd-tools.cjs": "b7968e3e3af00249", + "gsd-core/bin/gsd_run": "62d9b647ede212e6", + "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", + "gsd-core/bin/shared/config-schema.manifest.json": "67e4addbfd248a7c", + "gsd-core/bin/shared/model-catalog.json": "dbe26e683236d8c6", + "gsd-core/bin/shared/runtime-aliases.manifest.json": "f6c8b3af10dcfdc4", + "gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904", + "gsd-core/contexts/dev.md": "dcb0de9dce33cf41", + "gsd-core/contexts/research.md": "b3285d8e7209cc3b", + "gsd-core/contexts/review.md": "dc578fdd74bbea11", + "gsd-core/references/agent-contracts.md": "ff65e633c656c0d2", + "gsd-core/references/ai-evals.md": "b5afa786b938671e", + "gsd-core/references/ai-frameworks.md": "425dd69c629230e7", + "gsd-core/references/artifact-types.md": "3218cafb0c92dc32", + "gsd-core/references/autonomous-smart-discuss.md": "4156025334411073", + "gsd-core/references/checkpoints.md": "9feb961f644afa96", + "gsd-core/references/common-bug-patterns.md": "780145be56352626", + "gsd-core/references/context-budget.md": "0b67ea1d5db4bc08", + "gsd-core/references/continuation-format.md": "e64c0da2b3d0f2f0", + "gsd-core/references/debugger-philosophy.md": "1cb61d4565081c88", + "gsd-core/references/decimal-phase-calculation.md": "ee363ec47be68922", + "gsd-core/references/doc-conflict-engine.md": "257aba80c93854d4", + "gsd-core/references/domain-probes.md": "4901deac8eac0f49", + "gsd-core/references/edge-probe-fixtures/01-round-half-even/expected-coverage.json": "72d1e29cedc854ec", + "gsd-core/references/edge-probe-fixtures/01-round-half-even/requirements.json": "fbc1b355d8625eeb", + "gsd-core/references/edge-probe-fixtures/02-merge-intervals/expected-coverage.json": "fad67dcc8294f6da", + "gsd-core/references/edge-probe-fixtures/02-merge-intervals/requirements.json": "30a78ee9ce3473ea", + "gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/expected-coverage.json": "66dd60957fee45f0", + "gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/requirements.json": "47fca61f076835fa", + "gsd-core/references/edge-probe-fixtures/04-money-rounding/expected-coverage.json": "72d1e29cedc854ec", + "gsd-core/references/edge-probe-fixtures/04-money-rounding/requirements.json": "80f04f5c04fb24cf", + "gsd-core/references/edge-probe-fixtures/05-list-dedupe/expected-coverage.json": "fad67dcc8294f6da", + "gsd-core/references/edge-probe-fixtures/05-list-dedupe/requirements.json": "d38147adb0e5b342", + "gsd-core/references/edge-probe-fixtures/06-resolved-mixed/expected-coverage.json": "bc552c01939bf4f8", + "gsd-core/references/edge-probe-fixtures/06-resolved-mixed/requirements.json": "30a78ee9ce3473ea", + "gsd-core/references/edge-probe-fixtures/06-resolved-mixed/resolutions.json": "688ec62c13e08afe", + "gsd-core/references/edge-probe.md": "5687eba25a078561", + "gsd-core/references/execute-mvp-tdd.md": "a98a270a7ab126bc", + "gsd-core/references/execute-phase-between-wave-reset.md": "3ad96ca0f7fee37e", + "gsd-core/references/execute-phase-context-guard.md": "a483199612e216f1", + "gsd-core/references/execute-phase-wave-guard.md": "95e55087876a0d96", + "gsd-core/references/executor-examples.md": "ba59243ed45c8ab1", + "gsd-core/references/few-shot-examples/plan-checker.md": "2574808188ac9de4", + "gsd-core/references/few-shot-examples/verifier.md": "5badee4560b14ae8", + "gsd-core/references/gate-prompts.md": "2d3efd04749f56d4", + "gsd-core/references/gates.md": "11fd2bdf27df57a5", + "gsd-core/references/git-integration.md": "94715b69448bb818", + "gsd-core/references/git-planning-commit.md": "5aad099c51135a0f", + "gsd-core/references/ios-scaffold.md": "5ef0cb7e0fac891f", + "gsd-core/references/loop-hook-dispatch.md": "32e5dfb4dba76987", + "gsd-core/references/mandatory-initial-read.md": "fe59abce693717cf", + "gsd-core/references/model-profile-resolution.md": "f32bb05102839767", + "gsd-core/references/model-profiles.md": "da9d5fcb1cf6eb4b", + "gsd-core/references/mvp-concepts.md": "23201c8118fb074a", + "gsd-core/references/phase-argument-parsing.md": "531176f66da49c98", + "gsd-core/references/planner-antipatterns.md": "2459c40bfc8a0ea8", + "gsd-core/references/planner-chunked.md": "79fe674221e738e6", + "gsd-core/references/planner-gap-closure.md": "76bee257911413e7", + "gsd-core/references/planner-graphify-auto-update.md": "ccd62140264bed71", + "gsd-core/references/planner-guidance.md": "5f62d3f50b8fc42b", + "gsd-core/references/planner-human-verify-mode.md": "3a625b42d9cb93ee", + "gsd-core/references/planner-interface-context.md": "b28fa3da6ae739a8", + "gsd-core/references/planner-load-graph-context.md": "ca7a7af3f35ae61b", + "gsd-core/references/planner-mvp-mode.md": "ec33050db81101a8", + "gsd-core/references/planner-reviews.md": "7889bfa28e82156b", + "gsd-core/references/planner-revision.md": "2ebf1a714d1ec4bf", + "gsd-core/references/planner-source-audit.md": "7de5bdb07232ce0b", + "gsd-core/references/planning-config.md": "0767f44905c9a65e", + "gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json": "f10df472f2846cc6", + "gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json": "31e8a781eeffe020", + "gsd-core/references/prohibition-probe-fixtures/03-multi-prohibition/expected.json": "70a532a7cc1b6ae8", + "gsd-core/references/prohibition-probe.md": "f0e320677bcd8e19", + "gsd-core/references/project-skills-discovery.md": "d3a8f760d63ea3b9", + "gsd-core/references/questioning.md": "8f26dfe5794b1e6e", + "gsd-core/references/research-documentation-lookup.md": "c070007d1d72ab71", + "gsd-core/references/research-philosophy.md": "a602b41bfa081fdf", + "gsd-core/references/research-verification-protocol.md": "9c38c9d9a687e679", + "gsd-core/references/revision-loop.md": "e55ff32dd98c63df", + "gsd-core/references/scout-codebase.md": "ba266ecc18fbf172", + "gsd-core/references/security-asvs-levels.md": "4774fac3b94b6ca8", + "gsd-core/references/skeleton-template.md": "528691d1f0efa878", + "gsd-core/references/sketch-interactivity.md": "7d982fe877e1e1cc", + "gsd-core/references/sketch-theme-system.md": "33e2e96e450456f8", + "gsd-core/references/sketch-tooling.md": "df6c4f24c1c27611", + "gsd-core/references/sketch-variant-patterns.md": "66c197aa4fb52810", + "gsd-core/references/spidr-splitting.md": "074ac154c0e4f906", + "gsd-core/references/tdd.md": "e4708ede157478b6", + "gsd-core/references/thinking-models-debug.md": "2da61022b16c4e7c", + "gsd-core/references/thinking-models-execution.md": "dcc650a8b5f3e049", + "gsd-core/references/thinking-models-planning.md": "7e19462313fa028f", + "gsd-core/references/thinking-models-research.md": "5f6bf3f3b889c6e4", + "gsd-core/references/thinking-models-verification.md": "a71a933d51ca3d8d", + "gsd-core/references/thinking-partner.md": "347c33b2d5646c93", + "gsd-core/references/ui-brand.md": "37a2dc822a4b77c4", + "gsd-core/references/universal-anti-patterns.md": "865f7ba442795487", + "gsd-core/references/untrusted-input-boundary.md": "d33b80d4d348599a", + "gsd-core/references/user-profiling.md": "8e023a908d968af1", + "gsd-core/references/user-story-template.md": "0cc50e06a144ff8a", + "gsd-core/references/verification-overrides.md": "0c8cac962c2bb105", + "gsd-core/references/verification-patterns.md": "ac45d99076067f17", + "gsd-core/references/verify-mvp-mode.md": "78faa99df1668aab", + "gsd-core/references/workstream-flag.md": "c764d9cbdf6faff4", + "gsd-core/references/worktree-branch-check.md": "21d9c31bf6542b93", + "gsd-core/references/worktree-path-safety.md": "3c8d74756f9b16a8", + "gsd-core/templates/AI-SPEC.md": "6411a4789f481c15", + "gsd-core/templates/DEBUG.md": "0f92d4581a15ab8f", + "gsd-core/templates/README.md": "dc13994dabe139a2", + "gsd-core/templates/SECURITY.md": "b628f7f1c6d2328f", + "gsd-core/templates/UAT.md": "59a3d4f6c4afbfc9", + "gsd-core/templates/UI-SPEC.md": "20ca56a4e3e21f01", + "gsd-core/templates/VALIDATION.md": "e3ca8ebb8d7e2cd0", + "gsd-core/templates/claude-md.md": "dd1a9011684f9753", + "gsd-core/templates/codebase/architecture.md": "282db635ba093b1a", + "gsd-core/templates/codebase/concerns.md": "e66c584daa636fc5", + "gsd-core/templates/codebase/conventions.md": "c5f0324730852701", + "gsd-core/templates/codebase/integrations.md": "39bd23c71eedd564", + "gsd-core/templates/codebase/stack.md": "116e7e67dd87ddec", + "gsd-core/templates/codebase/structure.md": "5b5dd37145241462", + "gsd-core/templates/codebase/testing.md": "76abff7f2050c9ea", + "gsd-core/templates/config.json": "1473dff11272d3c4", + "gsd-core/templates/context.md": "593d4e6d20fdde7c", + "gsd-core/templates/continue-here.md": "8b1dce4832c7922b", + "gsd-core/templates/copilot-instructions.md": "aea34bc52ff548ea", + "gsd-core/templates/debug-subagent-prompt.md": "a3948171649b686a", + "gsd-core/templates/dev-preferences.md": "975e1534eea2f695", + "gsd-core/templates/discovery.md": "7afefd109c2d5316", + "gsd-core/templates/discussion-log.md": "da4e8e266efdc6c2", + "gsd-core/templates/milestone-archive.md": "591b6decdc0c0e51", + "gsd-core/templates/milestone.md": "74d2f750ae9f4a9c", + "gsd-core/templates/phase-prompt.md": "56c67fdc5d31b556", + "gsd-core/templates/planner-subagent-prompt.md": "a48a5a2ebd5a9f47", + "gsd-core/templates/project.md": "034a6501f348c5a5", + "gsd-core/templates/requirements.md": "a44de4c2f146e473", + "gsd-core/templates/research-project/ARCHITECTURE.md": "746b9ef791d758b0", + "gsd-core/templates/research-project/FEATURES.md": "f2b800de5df91b0f", + "gsd-core/templates/research-project/PITFALLS.md": "3ef75fa768422eec", + "gsd-core/templates/research-project/STACK.md": "82c85799ac4dd344", + "gsd-core/templates/research-project/SUMMARY.md": "dceb2f346388839d", + "gsd-core/templates/research.md": "1485bc6d64c5d8ec", + "gsd-core/templates/retrospective.md": "03981e30dd760103", + "gsd-core/templates/roadmap.md": "e4e35a9eb5dd4d4f", + "gsd-core/templates/spec.md": "34cb8124f37c9b14", + "gsd-core/templates/state.md": "3bac0c4a26c094f9", + "gsd-core/templates/summary-complex.md": "a5e40574fd8894dc", + "gsd-core/templates/summary-minimal.md": "7d09b5e709e2e67c", + "gsd-core/templates/summary-standard.md": "e8d9cf4a8377cdff", + "gsd-core/templates/summary.md": "f612660bfd0a325a", + "gsd-core/templates/user-profile.md": "52abe2af968e8533", + "gsd-core/templates/user-setup.md": "1da2382725db080f", + "gsd-core/templates/verification-report.md": "78ab9264ce63ed7e", + "gsd-core/workflows/_runtime-launcher.snippet.sh": "c0994e00f976dbfb", + "gsd-core/workflows/add-backlog.md": "f33efb61b6810afd", + "gsd-core/workflows/add-phase.md": "b522eb805a7f00be", + "gsd-core/workflows/add-tests.md": "1157f7142099e956", + "gsd-core/workflows/add-todo.md": "218d02344535d232", + "gsd-core/workflows/ai-integration-phase.md": "7e3b8a29d2a71d80", + "gsd-core/workflows/analyze-dependencies.md": "f799abc00907377f", + "gsd-core/workflows/audit-fix.md": "eaead7ea85761e5b", + "gsd-core/workflows/audit-milestone.md": "28afeeab183e254f", + "gsd-core/workflows/audit-uat.md": "b3b503e9f80dc9f5", + "gsd-core/workflows/autonomous.md": "d8ed420051f4d670", + "gsd-core/workflows/check-todos.md": "d847aa758e18d698", + "gsd-core/workflows/cleanup.md": "b990139192ab7c08", + "gsd-core/workflows/code-review-fix.md": "3311f5fa61f8a90b", + "gsd-core/workflows/code-review.md": "937aeabfff8963b3", + "gsd-core/workflows/complete-milestone.md": "d203f68731d9d325", + "gsd-core/workflows/debug.md": "e8367cffd3dfa758", + "gsd-core/workflows/diagnose-issues.md": "b6766f3830f5b130", + "gsd-core/workflows/discovery-phase.md": "71a4b78ff876a854", + "gsd-core/workflows/discuss-phase-assumptions.md": "9d9b1e0b0fbf6e92", + "gsd-core/workflows/discuss-phase-power.md": "3f8b83dc6be2e9d5", + "gsd-core/workflows/discuss-phase.md": "b84b5cd94c57b9af", + "gsd-core/workflows/discuss-phase/modes/advisor.md": "c7d4a3ff91655d24", + "gsd-core/workflows/discuss-phase/modes/all.md": "42e58e1f499f7824", + "gsd-core/workflows/discuss-phase/modes/analyze.md": "c824ef897199b730", + "gsd-core/workflows/discuss-phase/modes/auto.md": "48ac14a332568c30", + "gsd-core/workflows/discuss-phase/modes/batch.md": "21ee55869eaa7ece", + "gsd-core/workflows/discuss-phase/modes/chain.md": "1ff5f2e1654e93f1", + "gsd-core/workflows/discuss-phase/modes/default.md": "4c5ba5975dcc1e9c", + "gsd-core/workflows/discuss-phase/modes/power.md": "55b61b7dc83a58b2", + "gsd-core/workflows/discuss-phase/modes/text.md": "6e1c6f4d8fdd0be3", + "gsd-core/workflows/discuss-phase/templates/checkpoint.json": "e3bc3dca49db59eb", + "gsd-core/workflows/discuss-phase/templates/context.md": "9804674fccada405", + "gsd-core/workflows/discuss-phase/templates/discussion-log.md": "2b56ec2868cbddb4", + "gsd-core/workflows/do.md": "0792489ee3d22472", + "gsd-core/workflows/docs-update.md": "c5e1ea372f4a9ee8", + "gsd-core/workflows/edit-phase.md": "c83ef0701c19c455", + "gsd-core/workflows/eval-review.md": "68d7d96bd415629b", + "gsd-core/workflows/execute-phase.md": "f7fb1c335b621f62", + "gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md": "7ae407e4435c9a2a", + "gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md": "7ebb7d1af6082028", + "gsd-core/workflows/execute-phase/steps/post-merge-gate.md": "abd2aca069c04a80", + "gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md": "be84efbd71e1513e", + "gsd-core/workflows/execute-plan.md": "e67237068c3bc91d", + "gsd-core/workflows/explore.md": "a75d42453e639a5d", + "gsd-core/workflows/extract-learnings.md": "89145eb423bbbef2", + "gsd-core/workflows/fast.md": "13252d545947354d", + "gsd-core/workflows/forensics.md": "3db077a229e1ca88", + "gsd-core/workflows/graduation.md": "00d1b7fe7ed8578b", + "gsd-core/workflows/health.md": "e9fe7c29fc41a887", + "gsd-core/workflows/help.md": "08e1349950c5602a", + "gsd-core/workflows/help/modes/brief.md": "becceb85b25dca89", + "gsd-core/workflows/help/modes/default.md": "18c7dc50066f5cd6", + "gsd-core/workflows/help/modes/full.md": "9fec66a5a4eb8389", + "gsd-core/workflows/help/modes/topic.md": "fc6697bdb47df8b3", + "gsd-core/workflows/import.md": "8b8c3a25526c0d41", + "gsd-core/workflows/inbox.md": "61b8b10e7a74b2e9", + "gsd-core/workflows/ingest-docs.md": "3680699c20c3ac7d", + "gsd-core/workflows/insert-phase.md": "9f8286598bffe5de", + "gsd-core/workflows/list-phase-assumptions.md": "4d245f6ea899bcb9", + "gsd-core/workflows/list-seeds.md": "1fb29a83e0769ced", + "gsd-core/workflows/list-workspaces.md": "7fbeaf65ce9890f5", + "gsd-core/workflows/manager.md": "4def33941b0e7d57", + "gsd-core/workflows/map-codebase.md": "320d5bfc9df29013", + "gsd-core/workflows/milestone-summary.md": "121ecb40504caf10", + "gsd-core/workflows/mvp-phase.md": "9a9b8b58d59a893f", + "gsd-core/workflows/new-milestone.md": "923f10697d743555", + "gsd-core/workflows/new-project.md": "f69c90cee1d7952d", + "gsd-core/workflows/new-workspace.md": "cc9660e79d5ba7b4", + "gsd-core/workflows/next.md": "ede3f6183e513000", + "gsd-core/workflows/node-repair.md": "07a1628e5a1ff96b", + "gsd-core/workflows/note.md": "664da466ab989f9d", + "gsd-core/workflows/pause-work.md": "3c2ee96295959527", + "gsd-core/workflows/plan-milestone-gaps.md": "8ee841bcc7adc836", + "gsd-core/workflows/plan-phase.md": "4bef5b532a009f9b", + "gsd-core/workflows/plan-review-convergence.md": "f27818025e279aa4", + "gsd-core/workflows/plant-seed.md": "bfa729ff2ad3441a", + "gsd-core/workflows/pr-branch.md": "3b13915429c0e8d9", + "gsd-core/workflows/profile-user.md": "1faa318720f40d31", + "gsd-core/workflows/progress.md": "3e061576e3f6fae1", + "gsd-core/workflows/quick.md": "276c8d1b4fc4be5d", + "gsd-core/workflows/reapply-patches.md": "fba79b3c55c3b2e7", + "gsd-core/workflows/remove-phase.md": "75c8ca0dbd1ce404", + "gsd-core/workflows/remove-workspace.md": "a9d1dcda7755b6c9", + "gsd-core/workflows/resume-project.md": "94ac2cac4c562557", + "gsd-core/workflows/review.md": "8c7288479748235f", + "gsd-core/workflows/scan.md": "29f3b65f5d9de885", + "gsd-core/workflows/secure-phase.md": "858b5e1152b569ed", + "gsd-core/workflows/session-report.md": "dd8fa011c9394075", + "gsd-core/workflows/settings-advanced.md": "bc781c33070b6d4f", + "gsd-core/workflows/settings-integrations.md": "d0443a29f3f924ec", + "gsd-core/workflows/settings.md": "3395b334321ac6fa", + "gsd-core/workflows/ship.md": "125045072ab5017d", + "gsd-core/workflows/sketch-wrap-up.md": "7709bdd7a70f736c", + "gsd-core/workflows/sketch.md": "bfe5a781d6da3ce8", + "gsd-core/workflows/spec-phase.md": "47cea2b5efffa6b0", + "gsd-core/workflows/spike-wrap-up.md": "53a2c51a8d9e6b08", + "gsd-core/workflows/spike.md": "c2f115f0d3251654", + "gsd-core/workflows/stats.md": "0d7449acf349feec", + "gsd-core/workflows/sync-skills.md": "e2b793963799f8ce", + "gsd-core/workflows/thread.md": "d3f768ce0f4b4a4d", + "gsd-core/workflows/transition.md": "c4bded570fafe712", + "gsd-core/workflows/ui-phase.md": "b373c964b222324c", + "gsd-core/workflows/ui-review.md": "a9b2cbba80482cd8", + "gsd-core/workflows/ultraplan-phase.md": "18bbb3b5fcd30f0c", + "gsd-core/workflows/undo.md": "5ff7d63b0a2f46d5", + "gsd-core/workflows/update.md": "4166fd3e3ca72a7b", + "gsd-core/workflows/validate-phase.md": "4e94708c9ca15d70", + "gsd-core/workflows/verify-phase.md": "b81bd1c061c9e6d3", + "gsd-core/workflows/verify-work.md": "92fb4e876d75ded5", + "hooks/gsd-check-update.js": "79c846cd8dd54caa", + "hooks/gsd-context-monitor.js": "caa8614524452835", + "scripts/changeset/README.md": "86ff89331dfd94b2", + "scripts/changeset/cli.cjs": "68f92a344b199271", + "scripts/changeset/github-release-notes.cjs": "795677f0c009b132", + "scripts/changeset/lint.cjs": "0066faed159154f0", + "scripts/changeset/new.cjs": "4991e21fd17f5541", + "scripts/changeset/parse.cjs": "f9a949cbcab56445", + "scripts/changeset/render.cjs": "e47bc3e1587c3cae", + "scripts/changeset/serialize.cjs": "ac0b8fe6f87cdb0e", + "scripts/fix-slash-commands.cjs": "0519742531ff3529", + "scripts/lib/allowlist-ratchet.cjs": "ffaceaac3efc2660", + "scripts/lib/cli-exit.cjs": "612d0c372c75b7e7", + "skills/gsd-add-tests/SKILL.md": "f3c1594a059de3ee", + "skills/gsd-ai-integration-phase/SKILL.md": "ba4b5f406db6de96", + "skills/gsd-audit-fix/SKILL.md": "9cea2764b92aa352", + "skills/gsd-audit-milestone/SKILL.md": "0528ef8a5834b5df", + "skills/gsd-audit-uat/SKILL.md": "6c2aa0c2b62c5233", + "skills/gsd-autonomous/SKILL.md": "d56dc692750f4926", + "skills/gsd-capture/SKILL.md": "211d601d122b5dd6", + "skills/gsd-cleanup/SKILL.md": "18cba17463c6ef97", + "skills/gsd-code-review/SKILL.md": "74749a1aff4224d7", + "skills/gsd-complete-milestone/SKILL.md": "77bee8741f8cb381", + "skills/gsd-config/SKILL.md": "5fde92e30619edcb", + "skills/gsd-debug/SKILL.md": "7e56964e14fd0a10", + "skills/gsd-discuss-phase/SKILL.md": "837d46c55cc2424e", + "skills/gsd-docs-update/SKILL.md": "5028dc5dd44bee7d", + "skills/gsd-eval-review/SKILL.md": "a300e78a27ba74d6", + "skills/gsd-execute-phase/SKILL.md": "e77bee13068600d3", + "skills/gsd-explore/SKILL.md": "d660cde0e6f31ebf", + "skills/gsd-extract-learnings/SKILL.md": "e32388999da384e4", + "skills/gsd-fast/SKILL.md": "14c407a76a40e115", + "skills/gsd-forensics/SKILL.md": "6d496a16f5ca74b3", + "skills/gsd-graphify/SKILL.md": "1bbd96129fbacc5c", + "skills/gsd-health/SKILL.md": "656c9c63852e3787", + "skills/gsd-help/SKILL.md": "28eb8d0b487239f8", + "skills/gsd-import/SKILL.md": "4d15ee09e531342f", + "skills/gsd-inbox/SKILL.md": "1315033595643485", + "skills/gsd-ingest-docs/SKILL.md": "5bd2838bf0b6dc1b", + "skills/gsd-manager/SKILL.md": "cb1cf56f5d3f66d6", + "skills/gsd-map-codebase/SKILL.md": "25d0b3adfc8b75dc", + "skills/gsd-mempalace-capture/SKILL.md": "65164de8f661d217", + "skills/gsd-mempalace-recall/SKILL.md": "4fd21539559aed6c", + "skills/gsd-milestone-summary/SKILL.md": "af84ecb400f23556", + "skills/gsd-mvp-phase/SKILL.md": "72b65ae927b280fe", + "skills/gsd-new-milestone/SKILL.md": "587574d2d475bb10", + "skills/gsd-new-project/SKILL.md": "129ac16e6a1f04ba", + "skills/gsd-ns-context/SKILL.md": "9b496da79789b3f9", + "skills/gsd-ns-ideate/SKILL.md": "84ca1cde06110981", + "skills/gsd-ns-manage/SKILL.md": "909dafa0cd19ba5a", + "skills/gsd-ns-project/SKILL.md": "fb8ad6c2223be3c3", + "skills/gsd-ns-review/SKILL.md": "022253010db0e072", + "skills/gsd-ns-workflow/SKILL.md": "14b6b1915178163f", + "skills/gsd-pause-work/SKILL.md": "b379469eed78a196", + "skills/gsd-phase/SKILL.md": "25477edc97a90c91", + "skills/gsd-plan-phase/SKILL.md": "19ead1acb151a868", + "skills/gsd-plan-review-convergence/SKILL.md": "f654089c11024027", + "skills/gsd-pr-branch/SKILL.md": "6901da15e321913e", + "skills/gsd-profile-user/SKILL.md": "6259fabfb6afe7be", + "skills/gsd-progress/SKILL.md": "85d76286162b9189", + "skills/gsd-quick/SKILL.md": "b4f4e711ba664aa0", + "skills/gsd-resume-work/SKILL.md": "04f6c2e5b579e8c4", + "skills/gsd-review-backlog/SKILL.md": "469696b944c4e7b5", + "skills/gsd-review/SKILL.md": "06044af5335989dc", + "skills/gsd-secure-phase/SKILL.md": "e64ad269c1e6e319", + "skills/gsd-settings/SKILL.md": "fcdda8dd545622ae", + "skills/gsd-ship/SKILL.md": "9615cc4c8f6de060", + "skills/gsd-sketch/SKILL.md": "90c219b843db7ec7", + "skills/gsd-spec-phase/SKILL.md": "56a5cbd606db9cba", + "skills/gsd-spike/SKILL.md": "77209fef7a04c11a", + "skills/gsd-stats/SKILL.md": "566024444ef71f44", + "skills/gsd-surface/SKILL.md": "492cda98187a969b", + "skills/gsd-thread/SKILL.md": "85326c97c83a02d1", + "skills/gsd-ui-phase/SKILL.md": "a0c8fbcbe5e3c2b9", + "skills/gsd-ui-review/SKILL.md": "081a3292a2d357f5", + "skills/gsd-ultraplan-phase/SKILL.md": "06fb3d76eb785f94", + "skills/gsd-undo/SKILL.md": "007d3b307e027af9", + "skills/gsd-update/SKILL.md": "e6e49e117c7c8f35", + "skills/gsd-validate-phase/SKILL.md": "373059517a94a194", + "skills/gsd-verify-work/SKILL.md": "4272275698e9a3fe", + "skills/gsd-workspace/SKILL.md": "06d6400d68318361", + "skills/gsd-workstreams/SKILL.md": "2f77bb94db1be1a4" +} diff --git a/tests/fixtures/golden-install-parity/copilot.json b/tests/fixtures/golden-install-parity/copilot.json new file mode 100644 index 000000000..089f1aef5 --- /dev/null +++ b/tests/fixtures/golden-install-parity/copilot.json @@ -0,0 +1,383 @@ +{ + ".gsd-profile": "0e716a5fef4e6dc1", + ".gsd/defaults.json": "560664b045e645cb", + "agents/gsd-advisor-researcher.agent.md": "0cd523fc827d40fd", + "agents/gsd-ai-researcher.agent.md": "36b15690217a4a07", + "agents/gsd-assumptions-analyzer.agent.md": "2d812e0bb0a04885", + "agents/gsd-code-fixer.agent.md": "32434a73c367faec", + "agents/gsd-code-reviewer.agent.md": "a0f0b6eafca6cf05", + "agents/gsd-codebase-mapper.agent.md": "c0d3dfa3fd9d3d8b", + "agents/gsd-debug-session-manager.agent.md": "dcfe78dafab52ae8", + "agents/gsd-debugger.agent.md": "0f7931c3e5c9f03c", + "agents/gsd-doc-classifier.agent.md": "c5025847cf6f968a", + "agents/gsd-doc-synthesizer.agent.md": "16cebf04d8998356", + "agents/gsd-doc-verifier.agent.md": "0230208ae7ab1a08", + "agents/gsd-doc-writer.agent.md": "a5aba58ecf1a6870", + "agents/gsd-domain-researcher.agent.md": "b06738b093df1cb9", + "agents/gsd-eval-auditor.agent.md": "6a7c7662aeb002a0", + "agents/gsd-eval-planner.agent.md": "14751876fc2b5f16", + "agents/gsd-executor.agent.md": "2ace688fe21cb778", + "agents/gsd-framework-selector.agent.md": "cafeec0b3489be45", + "agents/gsd-integration-checker.agent.md": "f962228d14a39fd1", + "agents/gsd-intel-updater.agent.md": "21ea4bd0bb45cc2f", + "agents/gsd-mempalace-curator.agent.md": "bbb2c654e8fbf8fb", + "agents/gsd-nyquist-auditor.agent.md": "f90fdd2c63936a90", + "agents/gsd-pattern-mapper.agent.md": "b1f488b0fa6a2395", + "agents/gsd-phase-researcher.agent.md": "a18855397c24b86d", + "agents/gsd-plan-checker.agent.md": "f425e78f7eaf2cfe", + "agents/gsd-planner.agent.md": "d67b2cf058055a82", + "agents/gsd-project-researcher.agent.md": "9cd4739389195a5f", + "agents/gsd-research-synthesizer.agent.md": "0ce237545afd492f", + "agents/gsd-roadmapper.agent.md": "03c946e0acc5d6b7", + "agents/gsd-security-auditor.agent.md": "8f858bb272471d04", + "agents/gsd-ui-auditor.agent.md": "d15ec6632ca12699", + "agents/gsd-ui-checker.agent.md": "4e46f787d6420062", + "agents/gsd-ui-researcher.agent.md": "9dd2acff7b24230e", + "agents/gsd-user-profiler.agent.md": "ae16a248e18dd42b", + "agents/gsd-verifier.agent.md": "ebd2c921c24e2d9b", + "copilot-instructions.md": "1fb04111759f1645", + "gsd-core/CHANGELOG.md": "e141e3fb369ff712", + "gsd-core/VERSION": "562368b20a64be95", + "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", + "gsd-core/bin/gsd-tools.cjs": "46deb2174be356dd", + "gsd-core/bin/gsd_run": "62d9b647ede212e6", + "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", + "gsd-core/bin/shared/config-schema.manifest.json": "67e4addbfd248a7c", + "gsd-core/bin/shared/model-catalog.json": "dbe26e683236d8c6", + "gsd-core/bin/shared/runtime-aliases.manifest.json": "f6c8b3af10dcfdc4", + "gsd-core/bin/verify-reapply-patches.cjs": "10226e9512dd44bf", + "gsd-core/contexts/dev.md": "dcb0de9dce33cf41", + "gsd-core/contexts/research.md": "b3285d8e7209cc3b", + "gsd-core/contexts/review.md": "dc578fdd74bbea11", + "gsd-core/references/agent-contracts.md": "ff65e633c656c0d2", + "gsd-core/references/ai-evals.md": "b5afa786b938671e", + "gsd-core/references/ai-frameworks.md": "425dd69c629230e7", + "gsd-core/references/artifact-types.md": "f992de8b2b1a4420", + "gsd-core/references/autonomous-smart-discuss.md": "efd80aca449032ad", + "gsd-core/references/checkpoints.md": "c70b323dcb1583d5", + "gsd-core/references/common-bug-patterns.md": "780145be56352626", + "gsd-core/references/context-budget.md": "435474d5e10be65a", + "gsd-core/references/continuation-format.md": "580287399ad3ba68", + "gsd-core/references/debugger-philosophy.md": "1cb61d4565081c88", + "gsd-core/references/decimal-phase-calculation.md": "46b5ba045852c474", + "gsd-core/references/doc-conflict-engine.md": "883d0a1b9d9ff96e", + "gsd-core/references/domain-probes.md": "762b965e84035b72", + "gsd-core/references/edge-probe-fixtures/01-round-half-even/expected-coverage.json": "72d1e29cedc854ec", + "gsd-core/references/edge-probe-fixtures/01-round-half-even/requirements.json": "fbc1b355d8625eeb", + "gsd-core/references/edge-probe-fixtures/02-merge-intervals/expected-coverage.json": "fad67dcc8294f6da", + "gsd-core/references/edge-probe-fixtures/02-merge-intervals/requirements.json": "30a78ee9ce3473ea", + "gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/expected-coverage.json": "66dd60957fee45f0", + "gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/requirements.json": "47fca61f076835fa", + "gsd-core/references/edge-probe-fixtures/04-money-rounding/expected-coverage.json": "72d1e29cedc854ec", + "gsd-core/references/edge-probe-fixtures/04-money-rounding/requirements.json": "80f04f5c04fb24cf", + "gsd-core/references/edge-probe-fixtures/05-list-dedupe/expected-coverage.json": "fad67dcc8294f6da", + "gsd-core/references/edge-probe-fixtures/05-list-dedupe/requirements.json": "d38147adb0e5b342", + "gsd-core/references/edge-probe-fixtures/06-resolved-mixed/expected-coverage.json": "bc552c01939bf4f8", + "gsd-core/references/edge-probe-fixtures/06-resolved-mixed/requirements.json": "30a78ee9ce3473ea", + "gsd-core/references/edge-probe-fixtures/06-resolved-mixed/resolutions.json": "688ec62c13e08afe", + "gsd-core/references/edge-probe.md": "5b91dca815b765e0", + "gsd-core/references/execute-mvp-tdd.md": "a98a270a7ab126bc", + "gsd-core/references/execute-phase-between-wave-reset.md": "3ad96ca0f7fee37e", + "gsd-core/references/execute-phase-context-guard.md": "a5a1058d35806a8e", + "gsd-core/references/execute-phase-wave-guard.md": "1eb1e1e552ebd17b", + "gsd-core/references/executor-examples.md": "ba59243ed45c8ab1", + "gsd-core/references/few-shot-examples/plan-checker.md": "2574808188ac9de4", + "gsd-core/references/few-shot-examples/verifier.md": "5badee4560b14ae8", + "gsd-core/references/gate-prompts.md": "43e867720e736464", + "gsd-core/references/gates.md": "7dc9fd3a3d6217c6", + "gsd-core/references/git-integration.md": "657a93c539c16cab", + "gsd-core/references/git-planning-commit.md": "f897a15ebfc3f5a7", + "gsd-core/references/ios-scaffold.md": "5ef0cb7e0fac891f", + "gsd-core/references/loop-hook-dispatch.md": "32e5dfb4dba76987", + "gsd-core/references/mandatory-initial-read.md": "fe59abce693717cf", + "gsd-core/references/model-profile-resolution.md": "1d392e37a746742a", + "gsd-core/references/model-profiles.md": "9e160d4e754b6b9e", + "gsd-core/references/mvp-concepts.md": "3464783eaaef5c10", + "gsd-core/references/phase-argument-parsing.md": "e5bbb985f3bc3e34", + "gsd-core/references/planner-antipatterns.md": "2459c40bfc8a0ea8", + "gsd-core/references/planner-chunked.md": "79fe674221e738e6", + "gsd-core/references/planner-gap-closure.md": "76bee257911413e7", + "gsd-core/references/planner-graphify-auto-update.md": "1ed614dfba72f2a3", + "gsd-core/references/planner-guidance.md": "ccb2eab7d941313c", + "gsd-core/references/planner-human-verify-mode.md": "5262b23d822d9541", + "gsd-core/references/planner-interface-context.md": "b28fa3da6ae739a8", + "gsd-core/references/planner-load-graph-context.md": "641b6c1ce4dd0c8c", + "gsd-core/references/planner-mvp-mode.md": "35890221f823756f", + "gsd-core/references/planner-reviews.md": "da39eace09a10743", + "gsd-core/references/planner-revision.md": "86ba8a511f081f05", + "gsd-core/references/planner-source-audit.md": "7de5bdb07232ce0b", + "gsd-core/references/planning-config.md": "ea950944302fba67", + "gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json": "f10df472f2846cc6", + "gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json": "31e8a781eeffe020", + "gsd-core/references/prohibition-probe-fixtures/03-multi-prohibition/expected.json": "70a532a7cc1b6ae8", + "gsd-core/references/prohibition-probe.md": "55178368c9d049d2", + "gsd-core/references/project-skills-discovery.md": "625f6e30257e10ee", + "gsd-core/references/questioning.md": "8f26dfe5794b1e6e", + "gsd-core/references/research-documentation-lookup.md": "c070007d1d72ab71", + "gsd-core/references/research-philosophy.md": "a602b41bfa081fdf", + "gsd-core/references/research-verification-protocol.md": "9c38c9d9a687e679", + "gsd-core/references/revision-loop.md": "e55ff32dd98c63df", + "gsd-core/references/scout-codebase.md": "ba266ecc18fbf172", + "gsd-core/references/security-asvs-levels.md": "4774fac3b94b6ca8", + "gsd-core/references/skeleton-template.md": "528691d1f0efa878", + "gsd-core/references/sketch-interactivity.md": "7d982fe877e1e1cc", + "gsd-core/references/sketch-theme-system.md": "33e2e96e450456f8", + "gsd-core/references/sketch-tooling.md": "df6c4f24c1c27611", + "gsd-core/references/sketch-variant-patterns.md": "66c197aa4fb52810", + "gsd-core/references/spidr-splitting.md": "074ac154c0e4f906", + "gsd-core/references/tdd.md": "e4708ede157478b6", + "gsd-core/references/thinking-models-debug.md": "2da61022b16c4e7c", + "gsd-core/references/thinking-models-execution.md": "dcc650a8b5f3e049", + "gsd-core/references/thinking-models-planning.md": "7e19462313fa028f", + "gsd-core/references/thinking-models-research.md": "5f6bf3f3b889c6e4", + "gsd-core/references/thinking-models-verification.md": "a71a933d51ca3d8d", + "gsd-core/references/thinking-partner.md": "827c1badf3e6df41", + "gsd-core/references/ui-brand.md": "48717bcfcd63bd27", + "gsd-core/references/universal-anti-patterns.md": "6a1245050b21df01", + "gsd-core/references/untrusted-input-boundary.md": "d33b80d4d348599a", + "gsd-core/references/user-profiling.md": "8e023a908d968af1", + "gsd-core/references/user-story-template.md": "0cc50e06a144ff8a", + "gsd-core/references/verification-overrides.md": "a3e2d5166d16a37b", + "gsd-core/references/verification-patterns.md": "a8e13ea38218638b", + "gsd-core/references/verify-mvp-mode.md": "534bdc7f2432903a", + "gsd-core/references/workstream-flag.md": "225910640adbb76d", + "gsd-core/references/worktree-branch-check.md": "21d9c31bf6542b93", + "gsd-core/references/worktree-path-safety.md": "3c8d74756f9b16a8", + "gsd-core/templates/AI-SPEC.md": "24df5fe5ba34e367", + "gsd-core/templates/DEBUG.md": "a2eea97e7f1336bf", + "gsd-core/templates/README.md": "da29c64b438065b3", + "gsd-core/templates/SECURITY.md": "b628f7f1c6d2328f", + "gsd-core/templates/UAT.md": "68d32d1fea14e184", + "gsd-core/templates/UI-SPEC.md": "20ca56a4e3e21f01", + "gsd-core/templates/VALIDATION.md": "6144951011cdca57", + "gsd-core/templates/claude-md.md": "f436ae75a9c8518a", + "gsd-core/templates/codebase/architecture.md": "282db635ba093b1a", + "gsd-core/templates/codebase/concerns.md": "e66c584daa636fc5", + "gsd-core/templates/codebase/conventions.md": "c5f0324730852701", + "gsd-core/templates/codebase/integrations.md": "39bd23c71eedd564", + "gsd-core/templates/codebase/stack.md": "116e7e67dd87ddec", + "gsd-core/templates/codebase/structure.md": "27318be7b7c984e1", + "gsd-core/templates/codebase/testing.md": "76abff7f2050c9ea", + "gsd-core/templates/config.json": "1473dff11272d3c4", + "gsd-core/templates/context.md": "593d4e6d20fdde7c", + "gsd-core/templates/continue-here.md": "8b1dce4832c7922b", + "gsd-core/templates/copilot-instructions.md": "aea34bc52ff548ea", + "gsd-core/templates/debug-subagent-prompt.md": "8c18a89e25929d8e", + "gsd-core/templates/dev-preferences.md": "95048a71063d980b", + "gsd-core/templates/discovery.md": "e4ab738326eb70e0", + "gsd-core/templates/discussion-log.md": "da4e8e266efdc6c2", + "gsd-core/templates/milestone-archive.md": "591b6decdc0c0e51", + "gsd-core/templates/milestone.md": "74d2f750ae9f4a9c", + "gsd-core/templates/phase-prompt.md": "e2b07376944c84e8", + "gsd-core/templates/planner-subagent-prompt.md": "6c9f1b23ee3dc05f", + "gsd-core/templates/project.md": "e4a3b331be081e62", + "gsd-core/templates/requirements.md": "a44de4c2f146e473", + "gsd-core/templates/research-project/ARCHITECTURE.md": "746b9ef791d758b0", + "gsd-core/templates/research-project/FEATURES.md": "f2b800de5df91b0f", + "gsd-core/templates/research-project/PITFALLS.md": "3ef75fa768422eec", + "gsd-core/templates/research-project/STACK.md": "82c85799ac4dd344", + "gsd-core/templates/research-project/SUMMARY.md": "dceb2f346388839d", + "gsd-core/templates/research.md": "b454f96fdf8b1d3d", + "gsd-core/templates/retrospective.md": "03981e30dd760103", + "gsd-core/templates/roadmap.md": "e4e35a9eb5dd4d4f", + "gsd-core/templates/spec.md": "8734f0df4df3a34b", + "gsd-core/templates/state.md": "a45a134631efe3f9", + "gsd-core/templates/summary-complex.md": "a5e40574fd8894dc", + "gsd-core/templates/summary-minimal.md": "7d09b5e709e2e67c", + "gsd-core/templates/summary-standard.md": "e8d9cf4a8377cdff", + "gsd-core/templates/summary.md": "23c40f6503b3ea98", + "gsd-core/templates/user-profile.md": "52abe2af968e8533", + "gsd-core/templates/user-setup.md": "1da2382725db080f", + "gsd-core/templates/verification-report.md": "78ab9264ce63ed7e", + "gsd-core/workflows/_runtime-launcher.snippet.sh": "c0994e00f976dbfb", + "gsd-core/workflows/add-backlog.md": "e8483ee6b695820f", + "gsd-core/workflows/add-phase.md": "ccd0c5c85ea3172c", + "gsd-core/workflows/add-tests.md": "3868acebc8a61874", + "gsd-core/workflows/add-todo.md": "69ff299331bbedf1", + "gsd-core/workflows/ai-integration-phase.md": "4b802634f8dfcf58", + "gsd-core/workflows/analyze-dependencies.md": "77aff48f97fa6f1c", + "gsd-core/workflows/audit-fix.md": "bd88277e075ec47f", + "gsd-core/workflows/audit-milestone.md": "3c1624402b54073d", + "gsd-core/workflows/audit-uat.md": "eccd8feb247425b0", + "gsd-core/workflows/autonomous.md": "00fa6860aa7653a8", + "gsd-core/workflows/check-todos.md": "4cdc0f448c772fbf", + "gsd-core/workflows/cleanup.md": "a67cfbd98eca5b86", + "gsd-core/workflows/code-review-fix.md": "84d5f91b2856a1f2", + "gsd-core/workflows/code-review.md": "fd8cd5c87ab977c2", + "gsd-core/workflows/complete-milestone.md": "f1a93edd3480bcf7", + "gsd-core/workflows/debug.md": "d9a9d4c858da1314", + "gsd-core/workflows/diagnose-issues.md": "3810d5329109a87e", + "gsd-core/workflows/discovery-phase.md": "8e99da61fb2b7074", + "gsd-core/workflows/discuss-phase-assumptions.md": "38ba3a06ab79f1ce", + "gsd-core/workflows/discuss-phase-power.md": "5c0d1ca6abda0383", + "gsd-core/workflows/discuss-phase.md": "1f4b3bbe790b46c8", + "gsd-core/workflows/discuss-phase/modes/advisor.md": "1247a438ddb8ba36", + "gsd-core/workflows/discuss-phase/modes/all.md": "e5fe9c9c1e2bec1a", + "gsd-core/workflows/discuss-phase/modes/analyze.md": "da0788f3be7f8105", + "gsd-core/workflows/discuss-phase/modes/auto.md": "7609f2b4b75f41ea", + "gsd-core/workflows/discuss-phase/modes/batch.md": "6946597770e2d448", + "gsd-core/workflows/discuss-phase/modes/chain.md": "b54a5ee44b9d3a4b", + "gsd-core/workflows/discuss-phase/modes/default.md": "4c5ba5975dcc1e9c", + "gsd-core/workflows/discuss-phase/modes/power.md": "9c97731f22d7bce9", + "gsd-core/workflows/discuss-phase/modes/text.md": "c384c22ffff4dc02", + "gsd-core/workflows/discuss-phase/templates/checkpoint.json": "e3bc3dca49db59eb", + "gsd-core/workflows/discuss-phase/templates/context.md": "9804674fccada405", + "gsd-core/workflows/discuss-phase/templates/discussion-log.md": "2b56ec2868cbddb4", + "gsd-core/workflows/do.md": "4fe59d153e5d36b9", + "gsd-core/workflows/docs-update.md": "15436f1f87ce9bd2", + "gsd-core/workflows/edit-phase.md": "a619911fd32d1f8e", + "gsd-core/workflows/eval-review.md": "b29095c5e7149975", + "gsd-core/workflows/execute-phase.md": "fb2a96db43d28f8d", + "gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md": "8d835dbbc9bc72cf", + "gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md": "7ebb7d1af6082028", + "gsd-core/workflows/execute-phase/steps/post-merge-gate.md": "a01e6aae9f3e416e", + "gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md": "be84efbd71e1513e", + "gsd-core/workflows/execute-plan.md": "da6dddd551737699", + "gsd-core/workflows/explore.md": "072bcf510734657b", + "gsd-core/workflows/extract-learnings.md": "83d19c1c9f87f797", + "gsd-core/workflows/fast.md": "54fe93778b45a7eb", + "gsd-core/workflows/forensics.md": "54e9996cd7bcc05d", + "gsd-core/workflows/graduation.md": "e64a735e71c39307", + "gsd-core/workflows/health.md": "56d1a87cf530c9a8", + "gsd-core/workflows/help.md": "5d040504b9ab35e3", + "gsd-core/workflows/help/modes/brief.md": "2e923b3ed635d495", + "gsd-core/workflows/help/modes/default.md": "b544113eb9aa54fa", + "gsd-core/workflows/help/modes/full.md": "bb4ee969b98be538", + "gsd-core/workflows/help/modes/topic.md": "a404990fcf2d7df7", + "gsd-core/workflows/import.md": "0e4a8880126d196b", + "gsd-core/workflows/inbox.md": "a448220c548f27bc", + "gsd-core/workflows/ingest-docs.md": "c34b9511b9ea92d8", + "gsd-core/workflows/insert-phase.md": "fc8452ee57b3dbf8", + "gsd-core/workflows/list-phase-assumptions.md": "e7b5e35c9452e3b3", + "gsd-core/workflows/list-seeds.md": "7040c63f68b59c10", + "gsd-core/workflows/list-workspaces.md": "e8e224465d78e733", + "gsd-core/workflows/manager.md": "c4eac43acf69d7fd", + "gsd-core/workflows/map-codebase.md": "576d06b03edff8db", + "gsd-core/workflows/milestone-summary.md": "20d93b795568d970", + "gsd-core/workflows/mvp-phase.md": "803ddbd575f86311", + "gsd-core/workflows/new-milestone.md": "34888afc152da98e", + "gsd-core/workflows/new-project.md": "d0af61f0e5f99485", + "gsd-core/workflows/new-workspace.md": "e61ad52a17e7570a", + "gsd-core/workflows/next.md": "a4cb2a110e0bce2c", + "gsd-core/workflows/node-repair.md": "07a1628e5a1ff96b", + "gsd-core/workflows/note.md": "4a5ee74cf2fc1f54", + "gsd-core/workflows/pause-work.md": "324e04e675dc7f7f", + "gsd-core/workflows/plan-milestone-gaps.md": "c0eb896eb42e22d4", + "gsd-core/workflows/plan-phase.md": "50042ca359933c51", + "gsd-core/workflows/plan-review-convergence.md": "c238b5858ceb4e57", + "gsd-core/workflows/plant-seed.md": "56451bdf104983b3", + "gsd-core/workflows/pr-branch.md": "a080aed95785cf32", + "gsd-core/workflows/profile-user.md": "f19b17f34ebd4527", + "gsd-core/workflows/progress.md": "09d94d774537e7e0", + "gsd-core/workflows/quick.md": "6700d7e6cd462aeb", + "gsd-core/workflows/reapply-patches.md": "7e1d16d9a9ccbb03", + "gsd-core/workflows/remove-phase.md": "e94ddfe4eabc0e08", + "gsd-core/workflows/remove-workspace.md": "f28be7f32249f0d4", + "gsd-core/workflows/resume-project.md": "4ebcb4acd3c29302", + "gsd-core/workflows/review.md": "58557fe552b6ff89", + "gsd-core/workflows/scan.md": "28a2847b8d04156e", + "gsd-core/workflows/secure-phase.md": "bae509fa254fe435", + "gsd-core/workflows/session-report.md": "2e5b1205324ddefa", + "gsd-core/workflows/settings-advanced.md": "dd145f9529a2502c", + "gsd-core/workflows/settings-integrations.md": "58e6551f9f342736", + "gsd-core/workflows/settings.md": "5dda66befcbb3ad8", + "gsd-core/workflows/ship.md": "0ee4d6bc7e34f716", + "gsd-core/workflows/sketch-wrap-up.md": "804bb69e6c3590a0", + "gsd-core/workflows/sketch.md": "b4d17647e0fb9e5d", + "gsd-core/workflows/spec-phase.md": "117abebab62d6df7", + "gsd-core/workflows/spike-wrap-up.md": "e203ed8e057f654c", + "gsd-core/workflows/spike.md": "716d74cdb2e39a3e", + "gsd-core/workflows/stats.md": "49085df6d4793df3", + "gsd-core/workflows/sync-skills.md": "eca50ffe8320dba8", + "gsd-core/workflows/thread.md": "4c44f10d41740f1d", + "gsd-core/workflows/transition.md": "724b6e9b34d85f26", + "gsd-core/workflows/ui-phase.md": "9fefa0db49f2aa3f", + "gsd-core/workflows/ui-review.md": "731bca05f9770a86", + "gsd-core/workflows/ultraplan-phase.md": "2dda203295895bc1", + "gsd-core/workflows/undo.md": "ba1ef7aa80bef6bd", + "gsd-core/workflows/update.md": "712ab18a9b7c14f5", + "gsd-core/workflows/validate-phase.md": "f923eac9442b6053", + "gsd-core/workflows/verify-phase.md": "543845af6f701518", + "gsd-core/workflows/verify-work.md": "e253fb8e33c68fa4", + "hooks/gsd-session.json": "0a462834f2a28fee", + "scripts/changeset/README.md": "86ff89331dfd94b2", + "scripts/changeset/cli.cjs": "68f92a344b199271", + "scripts/changeset/github-release-notes.cjs": "795677f0c009b132", + "scripts/changeset/lint.cjs": "0066faed159154f0", + "scripts/changeset/new.cjs": "4991e21fd17f5541", + "scripts/changeset/parse.cjs": "f9a949cbcab56445", + "scripts/changeset/render.cjs": "e47bc3e1587c3cae", + "scripts/changeset/serialize.cjs": "ac0b8fe6f87cdb0e", + "scripts/fix-slash-commands.cjs": "0519742531ff3529", + "scripts/lib/allowlist-ratchet.cjs": "ffaceaac3efc2660", + "scripts/lib/cli-exit.cjs": "612d0c372c75b7e7", + "skills/gsd-add-tests/SKILL.md": "dfe3f8a07f34c438", + "skills/gsd-ai-integration-phase/SKILL.md": "7cdaa253924740b0", + "skills/gsd-audit-fix/SKILL.md": "ac3e209167e2171a", + "skills/gsd-audit-milestone/SKILL.md": "faa9473569f5fa4b", + "skills/gsd-audit-uat/SKILL.md": "00f0a430a5c43da0", + "skills/gsd-autonomous/SKILL.md": "14495e0ad75bc1fb", + "skills/gsd-capture/SKILL.md": "cbae7b34a19b53bb", + "skills/gsd-cleanup/SKILL.md": "d6eb72a251354d07", + "skills/gsd-code-review/SKILL.md": "bc47a62d7788d723", + "skills/gsd-complete-milestone/SKILL.md": "d92573108882ac2f", + "skills/gsd-config/SKILL.md": "2bbc2d3ee41d5360", + "skills/gsd-debug/SKILL.md": "7abbde8111d8592c", + "skills/gsd-discuss-phase/SKILL.md": "1a6496fbbf49df6d", + "skills/gsd-docs-update/SKILL.md": "abca4017f556ebb6", + "skills/gsd-eval-review/SKILL.md": "8efd5502979365d2", + "skills/gsd-execute-phase/SKILL.md": "40fb582d30596e03", + "skills/gsd-explore/SKILL.md": "410ba39431b4741d", + "skills/gsd-extract-learnings/SKILL.md": "1651508a604db325", + "skills/gsd-fast/SKILL.md": "ee4c04a1f009052a", + "skills/gsd-forensics/SKILL.md": "604425b1aff67ddb", + "skills/gsd-graphify/SKILL.md": "ad09359c401677a5", + "skills/gsd-health/SKILL.md": "f5a554c058adc41a", + "skills/gsd-help/SKILL.md": "effcefa49fba6aca", + "skills/gsd-import/SKILL.md": "1b3dde7a9bbdb928", + "skills/gsd-inbox/SKILL.md": "cb9ed3865b67c04f", + "skills/gsd-ingest-docs/SKILL.md": "10500de29525f6cf", + "skills/gsd-manager/SKILL.md": "9946975bec15e737", + "skills/gsd-map-codebase/SKILL.md": "75806f8189d8af49", + "skills/gsd-mempalace-capture/SKILL.md": "d7e72629ec3772e2", + "skills/gsd-mempalace-recall/SKILL.md": "5f397524acf592d1", + "skills/gsd-milestone-summary/SKILL.md": "5ca1dabfe0cd85f0", + "skills/gsd-mvp-phase/SKILL.md": "60ac3c3fa6a6a19b", + "skills/gsd-new-milestone/SKILL.md": "e5eafe44e271bb92", + "skills/gsd-new-project/SKILL.md": "4ce68352221126c6", + "skills/gsd-ns-context/SKILL.md": "d61edde87ae53105", + "skills/gsd-ns-ideate/SKILL.md": "ca62cdf78ebe3585", + "skills/gsd-ns-manage/SKILL.md": "9b1b7f6d9b877fcd", + "skills/gsd-ns-project/SKILL.md": "f1a666fb76527e6b", + "skills/gsd-ns-review/SKILL.md": "6405d0b1cf3f245d", + "skills/gsd-ns-workflow/SKILL.md": "4f8a304f83832794", + "skills/gsd-pause-work/SKILL.md": "4f0caa008a8001ff", + "skills/gsd-phase/SKILL.md": "d38c7f9b1d0d2360", + "skills/gsd-plan-phase/SKILL.md": "d257bb9b1786039d", + "skills/gsd-plan-review-convergence/SKILL.md": "9338b49ecddd4ae8", + "skills/gsd-pr-branch/SKILL.md": "9cd9740db385d95a", + "skills/gsd-profile-user/SKILL.md": "052a8e17ecda40f1", + "skills/gsd-progress/SKILL.md": "28b94a19f2bccb78", + "skills/gsd-quick/SKILL.md": "d66072399c0aa371", + "skills/gsd-resume-work/SKILL.md": "e3ab06060bdd9df0", + "skills/gsd-review-backlog/SKILL.md": "2af20161e555fb42", + "skills/gsd-review/SKILL.md": "980080d635e66afe", + "skills/gsd-secure-phase/SKILL.md": "8666d8933e02c74f", + "skills/gsd-settings/SKILL.md": "4ef66b6dc3a2b8ff", + "skills/gsd-ship/SKILL.md": "ea26e3a839afb372", + "skills/gsd-sketch/SKILL.md": "1187d843efabea37", + "skills/gsd-spec-phase/SKILL.md": "e402110c96d1f44f", + "skills/gsd-spike/SKILL.md": "de84271bdba0ef67", + "skills/gsd-stats/SKILL.md": "e0beaf89d27c8058", + "skills/gsd-surface/SKILL.md": "2f2647c7bd664605", + "skills/gsd-thread/SKILL.md": "8db4d6ebcf0a8898", + "skills/gsd-ui-phase/SKILL.md": "317081b3be7c536f", + "skills/gsd-ui-review/SKILL.md": "dc07bbe9094b6dbe", + "skills/gsd-ultraplan-phase/SKILL.md": "b528b261b43000cc", + "skills/gsd-undo/SKILL.md": "a9675a18d90cf91d", + "skills/gsd-update/SKILL.md": "94e3baab2c32b338", + "skills/gsd-validate-phase/SKILL.md": "6130f42daa172ceb", + "skills/gsd-verify-work/SKILL.md": "da83518143f85525", + "skills/gsd-workspace/SKILL.md": "d5ae2727cb5d9c0c", + "skills/gsd-workstreams/SKILL.md": "c9d172e6f971f846" +} diff --git a/tests/fixtures/golden-install-parity/cursor.json b/tests/fixtures/golden-install-parity/cursor.json new file mode 100644 index 000000000..3a3d0ec19 --- /dev/null +++ b/tests/fixtures/golden-install-parity/cursor.json @@ -0,0 +1,452 @@ +{ + ".gsd-profile": "0e716a5fef4e6dc1", + ".gsd/defaults.json": "560664b045e645cb", + "agents/gsd-advisor-researcher.md": "7dfdadd98ec1cccc", + "agents/gsd-ai-researcher.md": "0c7be272a6bf0ec6", + "agents/gsd-assumptions-analyzer.md": "dc5e9973ff0740d7", + "agents/gsd-code-fixer.md": "d5ccf417e00a2a30", + "agents/gsd-code-reviewer.md": "208cc79888f4afc3", + "agents/gsd-codebase-mapper.md": "a1930b15964fb5dc", + "agents/gsd-debug-session-manager.md": "b5ea52be3681f202", + "agents/gsd-debugger.md": "de22db5ea3c6dc40", + "agents/gsd-doc-classifier.md": "5807f1f7cfa40c42", + "agents/gsd-doc-synthesizer.md": "0897098024c17ccd", + "agents/gsd-doc-verifier.md": "9934ec11845d41c3", + "agents/gsd-doc-writer.md": "9569af1e7da67c7a", + "agents/gsd-domain-researcher.md": "56395dbdabf076f6", + "agents/gsd-eval-auditor.md": "fedfa9dacc1a710c", + "agents/gsd-eval-planner.md": "2049dac060d00eda", + "agents/gsd-executor.md": "eac0eacad4443ec2", + "agents/gsd-framework-selector.md": "4b77eebbe9288d80", + "agents/gsd-integration-checker.md": "cc0411b5ccc430f5", + "agents/gsd-intel-updater.md": "7f509a2fb5acb0fe", + "agents/gsd-mempalace-curator.md": "63676937f8970bf3", + "agents/gsd-nyquist-auditor.md": "f8a86a8bcdbb1a5d", + "agents/gsd-pattern-mapper.md": "1229c215677f740d", + "agents/gsd-phase-researcher.md": "e0002e198c1b927e", + "agents/gsd-plan-checker.md": "7a86d4b29de90549", + "agents/gsd-planner.md": "a83fd27f2d1fee34", + "agents/gsd-project-researcher.md": "e2f0675c872e369b", + "agents/gsd-research-synthesizer.md": "78dc4c71aed61878", + "agents/gsd-roadmapper.md": "9522c4b2ecc008ae", + "agents/gsd-security-auditor.md": "4980804a2e09047a", + "agents/gsd-ui-auditor.md": "a4f174ae28efc879", + "agents/gsd-ui-checker.md": "66b380ffcdfec7b5", + "agents/gsd-ui-researcher.md": "fe237aa42ccd9ad2", + "agents/gsd-user-profiler.md": "622220df0654b6bf", + "agents/gsd-verifier.md": "5ec35372f0a58d48", + "commands/gsd-add-tests.md": "1f89b16ab2cca426", + "commands/gsd-ai-integration-phase.md": "3ccac39673ffb92c", + "commands/gsd-audit-fix.md": "d88502ffa2151cec", + "commands/gsd-audit-milestone.md": "db525e85095d268a", + "commands/gsd-audit-uat.md": "70d1d0f175734d8f", + "commands/gsd-autonomous.md": "b4724d55cb75d902", + "commands/gsd-capture.md": "4071615ec4f8e92d", + "commands/gsd-cleanup.md": "b83caf1561ecd265", + "commands/gsd-code-review.md": "67cc90ca2ed00f06", + "commands/gsd-complete-milestone.md": "203e0b262ac5caff", + "commands/gsd-config.md": "3bc7743f39e28a92", + "commands/gsd-debug.md": "145a7f7f15436296", + "commands/gsd-discuss-phase.md": "3a484d3d237dbfc5", + "commands/gsd-docs-update.md": "d224ab2eeb46d98c", + "commands/gsd-eval-review.md": "bc16912f0a5584da", + "commands/gsd-execute-phase.md": "d0ce08060b83d88c", + "commands/gsd-explore.md": "dad2dfe948cd0d9f", + "commands/gsd-extract-learnings.md": "c4fb98df89e4f380", + "commands/gsd-fast.md": "184dd72f2f00203b", + "commands/gsd-forensics.md": "ff8a9a8f91c2c5e5", + "commands/gsd-graphify.md": "42bd74b6c09a75d6", + "commands/gsd-health.md": "0239b3eafb48000b", + "commands/gsd-help.md": "d7685d97f7fab1d5", + "commands/gsd-import.md": "02134b777c821174", + "commands/gsd-inbox.md": "e6bcee3f863d20db", + "commands/gsd-ingest-docs.md": "9e7f8757b9408c01", + "commands/gsd-manager.md": "f300b708a5487c76", + "commands/gsd-map-codebase.md": "cf50f4a600119c8a", + "commands/gsd-mempalace-capture.md": "eab7aa94d6e71138", + "commands/gsd-mempalace-recall.md": "7da498f89250cb0f", + "commands/gsd-milestone-summary.md": "adfad2cf43fc5aad", + "commands/gsd-mvp-phase.md": "e8debeee627a0bf2", + "commands/gsd-new-milestone.md": "64b997f99b1e6293", + "commands/gsd-new-project.md": "f1a6288e80cc16b0", + "commands/gsd-ns-context.md": "b551dcc549c6a23f", + "commands/gsd-ns-ideate.md": "32f561de74b1bc7b", + "commands/gsd-ns-manage.md": "24445a84bd817c5b", + "commands/gsd-ns-project.md": "b39b509ac4161d28", + "commands/gsd-ns-review.md": "c8550463fbab5e76", + "commands/gsd-ns-workflow.md": "8226be5dc9e99b90", + "commands/gsd-pause-work.md": "59630f05f95fff68", + "commands/gsd-phase.md": "9a073dcd0f934f90", + "commands/gsd-plan-phase.md": "57de92131fcb16b8", + "commands/gsd-plan-review-convergence.md": "bded2d831f8ac9de", + "commands/gsd-pr-branch.md": "ef2eedb0ed4295da", + "commands/gsd-profile-user.md": "0e99de36619c3b7d", + "commands/gsd-progress.md": "44a7c784dc98f735", + "commands/gsd-quick.md": "0061e478a896265d", + "commands/gsd-resume-work.md": "a98b447fffe07e1e", + "commands/gsd-review-backlog.md": "347abbe2fdad79c9", + "commands/gsd-review.md": "473b5a21ef15d510", + "commands/gsd-secure-phase.md": "1631b1ecedbee373", + "commands/gsd-settings.md": "8e4a2673e7c96cad", + "commands/gsd-ship.md": "de9d81fe35184ae2", + "commands/gsd-sketch.md": "021add6f8eb7e7f3", + "commands/gsd-spec-phase.md": "7c95987dcd7aa3fb", + "commands/gsd-spike.md": "8102cc426fa1f1b3", + "commands/gsd-stats.md": "eb39e4c4ec8eccea", + "commands/gsd-surface.md": "1fb3de1651eb182a", + "commands/gsd-thread.md": "fc5975fdb73eb045", + "commands/gsd-ui-phase.md": "b91b691e1eb007c2", + "commands/gsd-ui-review.md": "2f9842e07e7df4df", + "commands/gsd-ultraplan-phase.md": "2d50a8e37952daeb", + "commands/gsd-undo.md": "5504f2280ad1e6d4", + "commands/gsd-update.md": "1e0e80a3bfeebddf", + "commands/gsd-validate-phase.md": "9c37396572d1d58c", + "commands/gsd-verify-work.md": "86a42f859bc26dd6", + "commands/gsd-workspace.md": "5c40114e6af87e3d", + "commands/gsd-workstreams.md": "112660ceb663c750", + "gsd-core/CHANGELOG.md": "e141e3fb369ff712", + "gsd-core/VERSION": "562368b20a64be95", + "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", + "gsd-core/bin/gsd-tools.cjs": "46deb2174be356dd", + "gsd-core/bin/gsd_run": "62d9b647ede212e6", + "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", + "gsd-core/bin/shared/config-schema.manifest.json": "67e4addbfd248a7c", + "gsd-core/bin/shared/model-catalog.json": "dbe26e683236d8c6", + "gsd-core/bin/shared/runtime-aliases.manifest.json": "f6c8b3af10dcfdc4", + "gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904", + "gsd-core/contexts/dev.md": "dcb0de9dce33cf41", + "gsd-core/contexts/research.md": "b3285d8e7209cc3b", + "gsd-core/contexts/review.md": "dc578fdd74bbea11", + "gsd-core/references/agent-contracts.md": "ff65e633c656c0d2", + "gsd-core/references/ai-evals.md": "b5afa786b938671e", + "gsd-core/references/ai-frameworks.md": "f827de93dde124eb", + "gsd-core/references/artifact-types.md": "8bd01fd75a2ba70e", + "gsd-core/references/autonomous-smart-discuss.md": "273b371c5751f35a", + "gsd-core/references/checkpoints.md": "3001eeccb319781b", + "gsd-core/references/common-bug-patterns.md": "780145be56352626", + "gsd-core/references/context-budget.md": "304dcdab82a27623", + "gsd-core/references/continuation-format.md": "580287399ad3ba68", + "gsd-core/references/debugger-philosophy.md": "0466f95a3d6bfcd3", + "gsd-core/references/decimal-phase-calculation.md": "46b5ba045852c474", + "gsd-core/references/doc-conflict-engine.md": "883d0a1b9d9ff96e", + "gsd-core/references/domain-probes.md": "762b965e84035b72", + "gsd-core/references/edge-probe-fixtures/01-round-half-even/expected-coverage.json": "72d1e29cedc854ec", + "gsd-core/references/edge-probe-fixtures/01-round-half-even/requirements.json": "fbc1b355d8625eeb", + "gsd-core/references/edge-probe-fixtures/02-merge-intervals/expected-coverage.json": "fad67dcc8294f6da", + "gsd-core/references/edge-probe-fixtures/02-merge-intervals/requirements.json": "30a78ee9ce3473ea", + "gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/expected-coverage.json": "66dd60957fee45f0", + "gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/requirements.json": "47fca61f076835fa", + "gsd-core/references/edge-probe-fixtures/04-money-rounding/expected-coverage.json": "72d1e29cedc854ec", + "gsd-core/references/edge-probe-fixtures/04-money-rounding/requirements.json": "80f04f5c04fb24cf", + "gsd-core/references/edge-probe-fixtures/05-list-dedupe/expected-coverage.json": "fad67dcc8294f6da", + "gsd-core/references/edge-probe-fixtures/05-list-dedupe/requirements.json": "d38147adb0e5b342", + "gsd-core/references/edge-probe-fixtures/06-resolved-mixed/expected-coverage.json": "bc552c01939bf4f8", + "gsd-core/references/edge-probe-fixtures/06-resolved-mixed/requirements.json": "30a78ee9ce3473ea", + "gsd-core/references/edge-probe-fixtures/06-resolved-mixed/resolutions.json": "688ec62c13e08afe", + "gsd-core/references/edge-probe.md": "692b44ca096f96e6", + "gsd-core/references/execute-mvp-tdd.md": "a98a270a7ab126bc", + "gsd-core/references/execute-phase-between-wave-reset.md": "44e31bdae72ed5d5", + "gsd-core/references/execute-phase-context-guard.md": "a5a1058d35806a8e", + "gsd-core/references/execute-phase-wave-guard.md": "b0e27ed2d8405974", + "gsd-core/references/executor-examples.md": "ba59243ed45c8ab1", + "gsd-core/references/few-shot-examples/plan-checker.md": "2574808188ac9de4", + "gsd-core/references/few-shot-examples/verifier.md": "5badee4560b14ae8", + "gsd-core/references/gate-prompts.md": "099c8d52e3562336", + "gsd-core/references/gates.md": "7dc9fd3a3d6217c6", + "gsd-core/references/git-integration.md": "9840f521ad6612b5", + "gsd-core/references/git-planning-commit.md": "f897a15ebfc3f5a7", + "gsd-core/references/ios-scaffold.md": "5ef0cb7e0fac891f", + "gsd-core/references/loop-hook-dispatch.md": "32e5dfb4dba76987", + "gsd-core/references/mandatory-initial-read.md": "fe59abce693717cf", + "gsd-core/references/model-profile-resolution.md": "f32bb05102839767", + "gsd-core/references/model-profiles.md": "b7dccb17609c2a0f", + "gsd-core/references/mvp-concepts.md": "3464783eaaef5c10", + "gsd-core/references/phase-argument-parsing.md": "e562947d1bf5c5a4", + "gsd-core/references/planner-antipatterns.md": "7ed54ec1e2cc54ac", + "gsd-core/references/planner-chunked.md": "79fe674221e738e6", + "gsd-core/references/planner-gap-closure.md": "76bee257911413e7", + "gsd-core/references/planner-graphify-auto-update.md": "1ed614dfba72f2a3", + "gsd-core/references/planner-guidance.md": "140b1266eeb096cb", + "gsd-core/references/planner-human-verify-mode.md": "5e7925ad77d931d1", + "gsd-core/references/planner-interface-context.md": "b28fa3da6ae739a8", + "gsd-core/references/planner-load-graph-context.md": "ca7a7af3f35ae61b", + "gsd-core/references/planner-mvp-mode.md": "ec33050db81101a8", + "gsd-core/references/planner-reviews.md": "da39eace09a10743", + "gsd-core/references/planner-revision.md": "86ba8a511f081f05", + "gsd-core/references/planner-source-audit.md": "7de5bdb07232ce0b", + "gsd-core/references/planning-config.md": "e2330447f33f6609", + "gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json": "f10df472f2846cc6", + "gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json": "31e8a781eeffe020", + "gsd-core/references/prohibition-probe-fixtures/03-multi-prohibition/expected.json": "70a532a7cc1b6ae8", + "gsd-core/references/prohibition-probe.md": "8f3eb787e3845e2f", + "gsd-core/references/project-skills-discovery.md": "2d352e05e7773a6b", + "gsd-core/references/questioning.md": "faac32813d1735bd", + "gsd-core/references/research-documentation-lookup.md": "c070007d1d72ab71", + "gsd-core/references/research-philosophy.md": "62930e66cc979c1a", + "gsd-core/references/research-verification-protocol.md": "9c38c9d9a687e679", + "gsd-core/references/revision-loop.md": "e55ff32dd98c63df", + "gsd-core/references/scout-codebase.md": "ba266ecc18fbf172", + "gsd-core/references/security-asvs-levels.md": "4774fac3b94b6ca8", + "gsd-core/references/skeleton-template.md": "528691d1f0efa878", + "gsd-core/references/sketch-interactivity.md": "7d982fe877e1e1cc", + "gsd-core/references/sketch-theme-system.md": "33e2e96e450456f8", + "gsd-core/references/sketch-tooling.md": "df6c4f24c1c27611", + "gsd-core/references/sketch-variant-patterns.md": "66c197aa4fb52810", + "gsd-core/references/spidr-splitting.md": "074ac154c0e4f906", + "gsd-core/references/tdd.md": "e4708ede157478b6", + "gsd-core/references/thinking-models-debug.md": "2da61022b16c4e7c", + "gsd-core/references/thinking-models-execution.md": "dcc650a8b5f3e049", + "gsd-core/references/thinking-models-planning.md": "7e19462313fa028f", + "gsd-core/references/thinking-models-research.md": "5f6bf3f3b889c6e4", + "gsd-core/references/thinking-models-verification.md": "a71a933d51ca3d8d", + "gsd-core/references/thinking-partner.md": "827c1badf3e6df41", + "gsd-core/references/ui-brand.md": "48717bcfcd63bd27", + "gsd-core/references/universal-anti-patterns.md": "6a1245050b21df01", + "gsd-core/references/untrusted-input-boundary.md": "d33b80d4d348599a", + "gsd-core/references/user-profiling.md": "b50416fe57c1b321", + "gsd-core/references/user-story-template.md": "0cc50e06a144ff8a", + "gsd-core/references/verification-overrides.md": "a3e2d5166d16a37b", + "gsd-core/references/verification-patterns.md": "72f4d4814fdae9a8", + "gsd-core/references/verify-mvp-mode.md": "534bdc7f2432903a", + "gsd-core/references/workstream-flag.md": "19c02b83bd1692bf", + "gsd-core/references/worktree-branch-check.md": "ab004404d028cc0d", + "gsd-core/references/worktree-path-safety.md": "66de0b35266c807d", + "gsd-core/templates/AI-SPEC.md": "24df5fe5ba34e367", + "gsd-core/templates/DEBUG.md": "57bd61bfd1d98e7e", + "gsd-core/templates/README.md": "83a3d5b593587e83", + "gsd-core/templates/SECURITY.md": "b628f7f1c6d2328f", + "gsd-core/templates/UAT.md": "68d32d1fea14e184", + "gsd-core/templates/UI-SPEC.md": "20ca56a4e3e21f01", + "gsd-core/templates/VALIDATION.md": "6144951011cdca57", + "gsd-core/templates/claude-md.md": "d6d7da8b7817a04c", + "gsd-core/templates/codebase/architecture.md": "6be88214162fdd89", + "gsd-core/templates/codebase/concerns.md": "efa26d1fb5132f25", + "gsd-core/templates/codebase/conventions.md": "c2e07698dad6b364", + "gsd-core/templates/codebase/integrations.md": "39bd23c71eedd564", + "gsd-core/templates/codebase/stack.md": "116e7e67dd87ddec", + "gsd-core/templates/codebase/structure.md": "762b16f9a6488474", + "gsd-core/templates/codebase/testing.md": "76abff7f2050c9ea", + "gsd-core/templates/config.json": "1473dff11272d3c4", + "gsd-core/templates/context.md": "69b01e7909ea3f66", + "gsd-core/templates/continue-here.md": "f522a51b6895fba8", + "gsd-core/templates/copilot-instructions.md": "aea34bc52ff548ea", + "gsd-core/templates/debug-subagent-prompt.md": "8c18a89e25929d8e", + "gsd-core/templates/dev-preferences.md": "95048a71063d980b", + "gsd-core/templates/discovery.md": "e4ab738326eb70e0", + "gsd-core/templates/discussion-log.md": "cac1b48ec0f4dcb8", + "gsd-core/templates/milestone-archive.md": "591b6decdc0c0e51", + "gsd-core/templates/milestone.md": "74d2f750ae9f4a9c", + "gsd-core/templates/phase-prompt.md": "213ccd947451ff2b", + "gsd-core/templates/planner-subagent-prompt.md": "6c9f1b23ee3dc05f", + "gsd-core/templates/project.md": "1f687c7a88381080", + "gsd-core/templates/requirements.md": "a44de4c2f146e473", + "gsd-core/templates/research-project/ARCHITECTURE.md": "746b9ef791d758b0", + "gsd-core/templates/research-project/FEATURES.md": "f2b800de5df91b0f", + "gsd-core/templates/research-project/PITFALLS.md": "3ef75fa768422eec", + "gsd-core/templates/research-project/STACK.md": "82c85799ac4dd344", + "gsd-core/templates/research-project/SUMMARY.md": "dceb2f346388839d", + "gsd-core/templates/research.md": "fa6dfb2ff2e8d273", + "gsd-core/templates/retrospective.md": "03981e30dd760103", + "gsd-core/templates/roadmap.md": "e4e35a9eb5dd4d4f", + "gsd-core/templates/spec.md": "7dc900c355098d8b", + "gsd-core/templates/state.md": "73e424b8c70b765c", + "gsd-core/templates/summary-complex.md": "a5e40574fd8894dc", + "gsd-core/templates/summary-minimal.md": "7d09b5e709e2e67c", + "gsd-core/templates/summary-standard.md": "e8d9cf4a8377cdff", + "gsd-core/templates/summary.md": "23c40f6503b3ea98", + "gsd-core/templates/user-profile.md": "20749f23e4c413fc", + "gsd-core/templates/user-setup.md": "78b7d718b6e8d67c", + "gsd-core/templates/verification-report.md": "dd5faa6254183731", + "gsd-core/workflows/_runtime-launcher.snippet.sh": "c0994e00f976dbfb", + "gsd-core/workflows/add-backlog.md": "05276889f397ae70", + "gsd-core/workflows/add-phase.md": "27164a671e14a789", + "gsd-core/workflows/add-tests.md": "e8a1387538bf94c6", + "gsd-core/workflows/add-todo.md": "d17cd00fa6610666", + "gsd-core/workflows/ai-integration-phase.md": "f103a92a82055846", + "gsd-core/workflows/analyze-dependencies.md": "77aff48f97fa6f1c", + "gsd-core/workflows/audit-fix.md": "eaead7ea85761e5b", + "gsd-core/workflows/audit-milestone.md": "5e9a35af57344e04", + "gsd-core/workflows/audit-uat.md": "69dbdec25cae2f12", + "gsd-core/workflows/autonomous.md": "70065ffffd8886cf", + "gsd-core/workflows/check-todos.md": "a993c1e7b0eff1f6", + "gsd-core/workflows/cleanup.md": "2bd6ad5f107fe439", + "gsd-core/workflows/code-review-fix.md": "c62547be55f13dcd", + "gsd-core/workflows/code-review.md": "fcb3f24e4cecc737", + "gsd-core/workflows/complete-milestone.md": "023cc8aa729b8987", + "gsd-core/workflows/debug.md": "2e3c29ded1ce0d28", + "gsd-core/workflows/diagnose-issues.md": "fa7ae5bb16f424a4", + "gsd-core/workflows/discovery-phase.md": "7dcf150998559c11", + "gsd-core/workflows/discuss-phase-assumptions.md": "6b33545647b2fea4", + "gsd-core/workflows/discuss-phase-power.md": "290c0d83d783f9f6", + "gsd-core/workflows/discuss-phase.md": "ed3b4ba5216c2bdb", + "gsd-core/workflows/discuss-phase/modes/advisor.md": "5aa0d0e4198a5364", + "gsd-core/workflows/discuss-phase/modes/all.md": "d2a1d16e2508a0cd", + "gsd-core/workflows/discuss-phase/modes/analyze.md": "c824ef897199b730", + "gsd-core/workflows/discuss-phase/modes/auto.md": "be6041997f72b02e", + "gsd-core/workflows/discuss-phase/modes/batch.md": "c23d5bdbdb60362c", + "gsd-core/workflows/discuss-phase/modes/chain.md": "c3eab4e57f93bd27", + "gsd-core/workflows/discuss-phase/modes/default.md": "4ada4fe332c5c985", + "gsd-core/workflows/discuss-phase/modes/power.md": "55b61b7dc83a58b2", + "gsd-core/workflows/discuss-phase/modes/text.md": "adec29a0217b6664", + "gsd-core/workflows/discuss-phase/templates/checkpoint.json": "e3bc3dca49db59eb", + "gsd-core/workflows/discuss-phase/templates/context.md": "6cd929e989fe2b0f", + "gsd-core/workflows/discuss-phase/templates/discussion-log.md": "cd5d01c9ef84ab73", + "gsd-core/workflows/do.md": "e00573dc44d407b5", + "gsd-core/workflows/docs-update.md": "b05a59f3079f6fa7", + "gsd-core/workflows/edit-phase.md": "a3ab51739c5c021c", + "gsd-core/workflows/eval-review.md": "8cc5788e32c15783", + "gsd-core/workflows/execute-phase.md": "1816d1337fff9170", + "gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md": "c133828cf177a772", + "gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md": "7ebb7d1af6082028", + "gsd-core/workflows/execute-phase/steps/post-merge-gate.md": "abd2aca069c04a80", + "gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md": "be84efbd71e1513e", + "gsd-core/workflows/execute-plan.md": "0f88a132f66b3d8f", + "gsd-core/workflows/explore.md": "aee57d95497ca50a", + "gsd-core/workflows/extract-learnings.md": "ce4b5388074f19a3", + "gsd-core/workflows/fast.md": "0162075e44072447", + "gsd-core/workflows/forensics.md": "063dd4fe9eb8de15", + "gsd-core/workflows/graduation.md": "d00ac4dfe7e1cc43", + "gsd-core/workflows/health.md": "b53e17edbf70ff96", + "gsd-core/workflows/help.md": "08e1349950c5602a", + "gsd-core/workflows/help/modes/brief.md": "2e923b3ed635d495", + "gsd-core/workflows/help/modes/default.md": "37c5149aae232e61", + "gsd-core/workflows/help/modes/full.md": "58d35e918fb1f868", + "gsd-core/workflows/help/modes/topic.md": "9f00f5f94c5497aa", + "gsd-core/workflows/import.md": "791424ae67c9cf32", + "gsd-core/workflows/inbox.md": "797c287852eb8957", + "gsd-core/workflows/ingest-docs.md": "b8b06f0561e5e68f", + "gsd-core/workflows/insert-phase.md": "0ab06e370253622b", + "gsd-core/workflows/list-phase-assumptions.md": "eb986c301d59f620", + "gsd-core/workflows/list-seeds.md": "5c298515026e60b1", + "gsd-core/workflows/list-workspaces.md": "9b78bb9f71029169", + "gsd-core/workflows/manager.md": "cf0f01254eabfec7", + "gsd-core/workflows/map-codebase.md": "d43dffe5098a8684", + "gsd-core/workflows/milestone-summary.md": "40e3049e42ecde63", + "gsd-core/workflows/mvp-phase.md": "542d898e0e9b928f", + "gsd-core/workflows/new-milestone.md": "9912521d111bdd22", + "gsd-core/workflows/new-project.md": "559e93f61313311b", + "gsd-core/workflows/new-workspace.md": "7045e17f5dbdc204", + "gsd-core/workflows/next.md": "6c3900ed84f03670", + "gsd-core/workflows/node-repair.md": "07a1628e5a1ff96b", + "gsd-core/workflows/note.md": "1c1e466c764e3deb", + "gsd-core/workflows/pause-work.md": "0be71264eafd16dc", + "gsd-core/workflows/plan-milestone-gaps.md": "1976bf2001969719", + "gsd-core/workflows/plan-phase.md": "8975739befb097bc", + "gsd-core/workflows/plan-review-convergence.md": "783c5171101b26b9", + "gsd-core/workflows/plant-seed.md": "b28224f37faa9b95", + "gsd-core/workflows/pr-branch.md": "1f77535b6b156ad9", + "gsd-core/workflows/profile-user.md": "0d86d846075afe0e", + "gsd-core/workflows/progress.md": "098f9bc1243cf6db", + "gsd-core/workflows/quick.md": "3f9e1e6349a6adec", + "gsd-core/workflows/reapply-patches.md": "2d7dada9edec108b", + "gsd-core/workflows/remove-phase.md": "f76e1c2a4dd31a09", + "gsd-core/workflows/remove-workspace.md": "dadbb14d9033ea3f", + "gsd-core/workflows/resume-project.md": "3dcaa7abe1800d35", + "gsd-core/workflows/review.md": "c12c3029d8640d31", + "gsd-core/workflows/scan.md": "8e1bbf2eed1752ca", + "gsd-core/workflows/secure-phase.md": "7bd4c335484fd121", + "gsd-core/workflows/session-report.md": "2e5b1205324ddefa", + "gsd-core/workflows/settings-advanced.md": "63a3916bf75ec6b3", + "gsd-core/workflows/settings-integrations.md": "d6e399eea4b4f3f9", + "gsd-core/workflows/settings.md": "507edbebad95cfc0", + "gsd-core/workflows/ship.md": "597f9423dd372337", + "gsd-core/workflows/sketch-wrap-up.md": "6780757b2db165ac", + "gsd-core/workflows/sketch.md": "4221996fa3d87f7c", + "gsd-core/workflows/spec-phase.md": "fca5397bf040156d", + "gsd-core/workflows/spike-wrap-up.md": "ec64f0f7ab03ef9b", + "gsd-core/workflows/spike.md": "9e37f8067adf87b7", + "gsd-core/workflows/stats.md": "17b4f2059f4b4ef2", + "gsd-core/workflows/sync-skills.md": "b505e6f8331c0918", + "gsd-core/workflows/thread.md": "06e31fdaf02ccabc", + "gsd-core/workflows/transition.md": "96ce39403ca69594", + "gsd-core/workflows/ui-phase.md": "dea2f2d43a43e0d0", + "gsd-core/workflows/ui-review.md": "6b16a7f7783be471", + "gsd-core/workflows/ultraplan-phase.md": "752df97bb6c14ce2", + "gsd-core/workflows/undo.md": "18dec684fb1076f9", + "gsd-core/workflows/update.md": "a27dcfd2814bf2b1", + "gsd-core/workflows/validate-phase.md": "f513c28c01a44cb7", + "gsd-core/workflows/verify-phase.md": "452968b6becb18a1", + "gsd-core/workflows/verify-work.md": "24d323b667d15d01", + "hooks/gsd-cursor-post-tool.js": "019d503aee8b4a3f", + "hooks/gsd-cursor-session-start.js": "c6e04ed597ea7020", + "scripts/changeset/README.md": "86ff89331dfd94b2", + "scripts/changeset/cli.cjs": "68f92a344b199271", + "scripts/changeset/github-release-notes.cjs": "795677f0c009b132", + "scripts/changeset/lint.cjs": "0066faed159154f0", + "scripts/changeset/new.cjs": "4991e21fd17f5541", + "scripts/changeset/parse.cjs": "f9a949cbcab56445", + "scripts/changeset/render.cjs": "e47bc3e1587c3cae", + "scripts/changeset/serialize.cjs": "ac0b8fe6f87cdb0e", + "scripts/fix-slash-commands.cjs": "0519742531ff3529", + "scripts/lib/allowlist-ratchet.cjs": "ffaceaac3efc2660", + "scripts/lib/cli-exit.cjs": "612d0c372c75b7e7", + "skills/gsd-add-tests/SKILL.md": "2cfc1922a2c0f308", + "skills/gsd-ai-integration-phase/SKILL.md": "de0a4cba5ad1651e", + "skills/gsd-audit-fix/SKILL.md": "6f9986ff00316053", + "skills/gsd-audit-milestone/SKILL.md": "44f048f405be71d6", + "skills/gsd-audit-uat/SKILL.md": "df085a31c102fb9e", + "skills/gsd-autonomous/SKILL.md": "446aadb5cfbf52c6", + "skills/gsd-capture/SKILL.md": "9f6b83a85441693c", + "skills/gsd-cleanup/SKILL.md": "03ba6edc18b933d3", + "skills/gsd-code-review/SKILL.md": "6add7a9afc1f6a97", + "skills/gsd-complete-milestone/SKILL.md": "e10a29da26ba0bd4", + "skills/gsd-config/SKILL.md": "0284ff80c1ed0898", + "skills/gsd-debug/SKILL.md": "4b43729be1dc2eaf", + "skills/gsd-discuss-phase/SKILL.md": "c985a6f4c161fb4d", + "skills/gsd-docs-update/SKILL.md": "670c98be5994cb11", + "skills/gsd-eval-review/SKILL.md": "bb375b7ebbc416af", + "skills/gsd-execute-phase/SKILL.md": "695061c3dfea5b2c", + "skills/gsd-explore/SKILL.md": "34681d40134229b5", + "skills/gsd-extract-learnings/SKILL.md": "3073d3a5fe7ebd07", + "skills/gsd-fast/SKILL.md": "83eba8ec0f933cbe", + "skills/gsd-forensics/SKILL.md": "7375ebd3a971da09", + "skills/gsd-graphify/SKILL.md": "721039d5f186982d", + "skills/gsd-health/SKILL.md": "614dc5d4f9a6bc63", + "skills/gsd-help/SKILL.md": "e346f3202ffae1a1", + "skills/gsd-import/SKILL.md": "308a5ecc47871055", + "skills/gsd-inbox/SKILL.md": "f9b4ff17883ee7e4", + "skills/gsd-ingest-docs/SKILL.md": "01ed9183a00252ee", + "skills/gsd-manager/SKILL.md": "62cd9f671a39e8a4", + "skills/gsd-map-codebase/SKILL.md": "f529da27d3a30b09", + "skills/gsd-mempalace-capture/SKILL.md": "c6e93f22453145e8", + "skills/gsd-mempalace-recall/SKILL.md": "62c697606d67eddc", + "skills/gsd-milestone-summary/SKILL.md": "c5dbbc8edb97a7f7", + "skills/gsd-mvp-phase/SKILL.md": "9d8e2999fa9f7830", + "skills/gsd-new-milestone/SKILL.md": "642d0741c89b911a", + "skills/gsd-new-project/SKILL.md": "18f587ac0b390d94", + "skills/gsd-ns-context/SKILL.md": "dcf5711653d57354", + "skills/gsd-ns-ideate/SKILL.md": "0114f0bc5501c1e3", + "skills/gsd-ns-manage/SKILL.md": "f3232d05d5263f5b", + "skills/gsd-ns-project/SKILL.md": "9f737a544e8d3f31", + "skills/gsd-ns-review/SKILL.md": "8c686e92f293bfdd", + "skills/gsd-ns-workflow/SKILL.md": "180031baca2f6d24", + "skills/gsd-pause-work/SKILL.md": "10e7531a2cb392ad", + "skills/gsd-phase/SKILL.md": "1a2c9b64c1af7ffc", + "skills/gsd-plan-phase/SKILL.md": "0784da05d41dcfe7", + "skills/gsd-plan-review-convergence/SKILL.md": "6ffc6592e39f2e8a", + "skills/gsd-pr-branch/SKILL.md": "725727e3c2b66543", + "skills/gsd-profile-user/SKILL.md": "eb6842b23f1f9256", + "skills/gsd-progress/SKILL.md": "117243aef3e64b85", + "skills/gsd-quick/SKILL.md": "0fdc1838759e2d25", + "skills/gsd-resume-work/SKILL.md": "492e402cb66b1fcf", + "skills/gsd-review-backlog/SKILL.md": "cf7ff999c96d2f76", + "skills/gsd-review/SKILL.md": "8f6496868bb60e10", + "skills/gsd-secure-phase/SKILL.md": "970f53251c7adff7", + "skills/gsd-settings/SKILL.md": "c06a8c93de64314e", + "skills/gsd-ship/SKILL.md": "c7eeb21a15c66189", + "skills/gsd-sketch/SKILL.md": "44605f8ec5808162", + "skills/gsd-spec-phase/SKILL.md": "5c48117fbb7aa595", + "skills/gsd-spike/SKILL.md": "3850674027d81c2a", + "skills/gsd-stats/SKILL.md": "ebbd0d39b5cee9a9", + "skills/gsd-surface/SKILL.md": "805deb95c48af938", + "skills/gsd-thread/SKILL.md": "621cde6272262eef", + "skills/gsd-ui-phase/SKILL.md": "505ad61ef61c9e9c", + "skills/gsd-ui-review/SKILL.md": "dc79fe0ad42f4948", + "skills/gsd-ultraplan-phase/SKILL.md": "51c2fe437b7d9e22", + "skills/gsd-undo/SKILL.md": "886857ca7dbf15a5", + "skills/gsd-update/SKILL.md": "d87ddffc97e68ec5", + "skills/gsd-validate-phase/SKILL.md": "9148179afa585dad", + "skills/gsd-verify-work/SKILL.md": "28367b353ddfb65d", + "skills/gsd-workspace/SKILL.md": "9b1cf8be726b7b3a", + "skills/gsd-workstreams/SKILL.md": "55a94c63e32f85a6" +} diff --git a/tests/fixtures/golden-install-parity/gemini.json b/tests/fixtures/golden-install-parity/gemini.json new file mode 100644 index 000000000..bbe084e52 --- /dev/null +++ b/tests/fixtures/golden-install-parity/gemini.json @@ -0,0 +1,403 @@ +{ + ".gsd-profile": "0e716a5fef4e6dc1", + ".gsd/defaults.json": "560664b045e645cb", + "agents/gsd-advisor-researcher.md": "b8974f54d6cd2667", + "agents/gsd-ai-researcher.md": "7e4ed9746d91879c", + "agents/gsd-assumptions-analyzer.md": "94f13d08cbd6f01b", + "agents/gsd-code-fixer.md": "f09f115629154f10", + "agents/gsd-code-reviewer.md": "3b3b7e95c56c6938", + "agents/gsd-codebase-mapper.md": "1c4c8fdcecf34d47", + "agents/gsd-debug-session-manager.md": "e79b81434bd8b6ee", + "agents/gsd-debugger.md": "159427e280bb849e", + "agents/gsd-doc-classifier.md": "e2774e90fbf7d637", + "agents/gsd-doc-synthesizer.md": "f9350f45ec29bd78", + "agents/gsd-doc-verifier.md": "2ec24cbe0582bbe6", + "agents/gsd-doc-writer.md": "c0c01f730f866273", + "agents/gsd-domain-researcher.md": "ba1b6b24e2f8a952", + "agents/gsd-eval-auditor.md": "4ac9c6b9a952c0c3", + "agents/gsd-eval-planner.md": "ad757b253472269e", + "agents/gsd-executor.md": "e62fee7c05dd1636", + "agents/gsd-framework-selector.md": "1254f9adc2e95e4b", + "agents/gsd-integration-checker.md": "adc00e1a88278110", + "agents/gsd-intel-updater.md": "3111fd1028d155b5", + "agents/gsd-mempalace-curator.md": "ab78cc2c2e75c992", + "agents/gsd-nyquist-auditor.md": "dff34a7e2d844d6c", + "agents/gsd-pattern-mapper.md": "43a2ba37b24acdb2", + "agents/gsd-phase-researcher.md": "0161476db8048a22", + "agents/gsd-plan-checker.md": "3586ddf3d32a6708", + "agents/gsd-planner.md": "8e1515e240e0c8ab", + "agents/gsd-project-researcher.md": "545faffa64ffc15d", + "agents/gsd-research-synthesizer.md": "f23808b1e9387c2d", + "agents/gsd-roadmapper.md": "5a951f07136be592", + "agents/gsd-security-auditor.md": "3c2557ee3a5e3250", + "agents/gsd-ui-auditor.md": "82e9c855984e31ec", + "agents/gsd-ui-checker.md": "cb122369806c5467", + "agents/gsd-ui-researcher.md": "e1422e3b0f142f74", + "agents/gsd-user-profiler.md": "d6cb5430d841cea6", + "agents/gsd-verifier.md": "6f3d8f7d56b72475", + "commands/gsd/add-tests.toml": "297ba4d4c285fd99", + "commands/gsd/ai-integration-phase.toml": "411ba33b9a7d9e78", + "commands/gsd/audit-fix.toml": "f4a198a455f668a9", + "commands/gsd/audit-milestone.toml": "18842eaf6ed1807a", + "commands/gsd/audit-uat.toml": "c0239d3bd04986bf", + "commands/gsd/autonomous.toml": "4f4b576a7115d8b1", + "commands/gsd/capture.toml": "c1c5793b86c8f48b", + "commands/gsd/cleanup.toml": "8e6b1c74639e50f4", + "commands/gsd/code-review.toml": "ca94ab57acdc19e6", + "commands/gsd/complete-milestone.toml": "bb7089791fcae6f4", + "commands/gsd/config.toml": "eeae6354c86627db", + "commands/gsd/debug.toml": "55b7123102654b05", + "commands/gsd/discuss-phase.toml": "894826ab56cf5607", + "commands/gsd/docs-update.toml": "47e230a6ccc690e0", + "commands/gsd/eval-review.toml": "924a24261ca3fbe2", + "commands/gsd/execute-phase.toml": "04e76e06df6d726c", + "commands/gsd/explore.toml": "7f39ab33875ab2a4", + "commands/gsd/extract-learnings.toml": "f49b286ed791997f", + "commands/gsd/fast.toml": "41874bcaee134a50", + "commands/gsd/forensics.toml": "fc2286dc36a2be56", + "commands/gsd/graphify.toml": "feac28a2841a0425", + "commands/gsd/health.toml": "b1cbaa78f2a6d289", + "commands/gsd/help.toml": "c320e77702d95d87", + "commands/gsd/import.toml": "be6c5a8f9388e8cb", + "commands/gsd/inbox.toml": "63d2fddec20d38f1", + "commands/gsd/ingest-docs.toml": "cdc0a5130f64aaa4", + "commands/gsd/manager.toml": "e4164b936405c06b", + "commands/gsd/map-codebase.toml": "3f149a36061e4eea", + "commands/gsd/mempalace-capture.toml": "b6a089205e96820c", + "commands/gsd/mempalace-recall.toml": "18359171387de00b", + "commands/gsd/milestone-summary.toml": "03de0ae6e4d23ebc", + "commands/gsd/mvp-phase.toml": "fbaa7f45ec213bb5", + "commands/gsd/new-milestone.toml": "ca13145a1b414535", + "commands/gsd/new-project.toml": "ed9c9d44e2bad612", + "commands/gsd/ns-context.toml": "fc29ba4b8e8cd6ca", + "commands/gsd/ns-ideate.toml": "dd813a2d4dd91ade", + "commands/gsd/ns-manage.toml": "2fdb297bc681288e", + "commands/gsd/ns-project.toml": "81146d2a189556bc", + "commands/gsd/ns-review.toml": "2e48bcc682bb0c6a", + "commands/gsd/ns-workflow.toml": "409dd5a1848ef755", + "commands/gsd/pause-work.toml": "11899f0727c0ffe9", + "commands/gsd/phase.toml": "e062b8c130d3e954", + "commands/gsd/plan-phase.toml": "7d1c635bbdbd1f8b", + "commands/gsd/plan-review-convergence.toml": "aa71f85915b8b0de", + "commands/gsd/pr-branch.toml": "3ac7f2bf26c5acda", + "commands/gsd/profile-user.toml": "7192730d9bf899f5", + "commands/gsd/progress.toml": "bdc9ffaf4eee0b2f", + "commands/gsd/quick.toml": "b1f6d47488560def", + "commands/gsd/resume-work.toml": "5d1e36d643573d4c", + "commands/gsd/review-backlog.toml": "19461ca74224422c", + "commands/gsd/review.toml": "b8097bb984942e8e", + "commands/gsd/secure-phase.toml": "bbf0dc4d648f11fa", + "commands/gsd/settings.toml": "5369a6fbdce65ea9", + "commands/gsd/ship.toml": "fa360f1f63c8adec", + "commands/gsd/sketch.toml": "c0892fd97a9ed127", + "commands/gsd/spec-phase.toml": "fd8eed82220ae8d3", + "commands/gsd/spike.toml": "c9e37c9efa49cfbf", + "commands/gsd/stats.toml": "33ac1de9cfdb5e06", + "commands/gsd/surface.toml": "64e1d035854eb75d", + "commands/gsd/thread.toml": "ae86f2f38d8e1697", + "commands/gsd/ui-phase.toml": "300f0618f5756083", + "commands/gsd/ui-review.toml": "9a255ddc3ff1bd6e", + "commands/gsd/ultraplan-phase.toml": "c8c09e0313da7bfd", + "commands/gsd/undo.toml": "eea7699033036219", + "commands/gsd/update.toml": "20717a113502c7ac", + "commands/gsd/validate-phase.toml": "175c1e4ce78b7274", + "commands/gsd/verify-work.toml": "58ec25cb1f5d5434", + "commands/gsd/workspace.toml": "7f1658bb61c9743d", + "commands/gsd/workstreams.toml": "95f0c349b808a84c", + "gsd-core/CHANGELOG.md": "e141e3fb369ff712", + "gsd-core/VERSION": "562368b20a64be95", + "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", + "gsd-core/bin/gsd-tools.cjs": "b7968e3e3af00249", + "gsd-core/bin/gsd_run": "62d9b647ede212e6", + "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", + "gsd-core/bin/shared/config-schema.manifest.json": "67e4addbfd248a7c", + "gsd-core/bin/shared/model-catalog.json": "dbe26e683236d8c6", + "gsd-core/bin/shared/runtime-aliases.manifest.json": "f6c8b3af10dcfdc4", + "gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904", + "gsd-core/contexts/dev.md": "dcb0de9dce33cf41", + "gsd-core/contexts/research.md": "b3285d8e7209cc3b", + "gsd-core/contexts/review.md": "dc578fdd74bbea11", + "gsd-core/references/agent-contracts.md": "ff65e633c656c0d2", + "gsd-core/references/ai-evals.md": "b5afa786b938671e", + "gsd-core/references/ai-frameworks.md": "f827de93dde124eb", + "gsd-core/references/artifact-types.md": "a6d2e1f9453ffbf5", + "gsd-core/references/autonomous-smart-discuss.md": "273b371c5751f35a", + "gsd-core/references/checkpoints.md": "6aa620c6ca38bdf0", + "gsd-core/references/common-bug-patterns.md": "780145be56352626", + "gsd-core/references/context-budget.md": "f1ce57bf418824af", + "gsd-core/references/continuation-format.md": "a57656e8b4206ed4", + "gsd-core/references/debugger-philosophy.md": "0466f95a3d6bfcd3", + "gsd-core/references/decimal-phase-calculation.md": "46b5ba045852c474", + "gsd-core/references/doc-conflict-engine.md": "67d019d23e17f934", + "gsd-core/references/domain-probes.md": "62d23ed1992c48a9", + "gsd-core/references/edge-probe-fixtures/01-round-half-even/expected-coverage.json": "72d1e29cedc854ec", + "gsd-core/references/edge-probe-fixtures/01-round-half-even/requirements.json": "fbc1b355d8625eeb", + "gsd-core/references/edge-probe-fixtures/02-merge-intervals/expected-coverage.json": "fad67dcc8294f6da", + "gsd-core/references/edge-probe-fixtures/02-merge-intervals/requirements.json": "30a78ee9ce3473ea", + "gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/expected-coverage.json": "66dd60957fee45f0", + "gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/requirements.json": "47fca61f076835fa", + "gsd-core/references/edge-probe-fixtures/04-money-rounding/expected-coverage.json": "72d1e29cedc854ec", + "gsd-core/references/edge-probe-fixtures/04-money-rounding/requirements.json": "80f04f5c04fb24cf", + "gsd-core/references/edge-probe-fixtures/05-list-dedupe/expected-coverage.json": "fad67dcc8294f6da", + "gsd-core/references/edge-probe-fixtures/05-list-dedupe/requirements.json": "d38147adb0e5b342", + "gsd-core/references/edge-probe-fixtures/06-resolved-mixed/expected-coverage.json": "bc552c01939bf4f8", + "gsd-core/references/edge-probe-fixtures/06-resolved-mixed/requirements.json": "30a78ee9ce3473ea", + "gsd-core/references/edge-probe-fixtures/06-resolved-mixed/resolutions.json": "688ec62c13e08afe", + "gsd-core/references/edge-probe.md": "5687eba25a078561", + "gsd-core/references/execute-mvp-tdd.md": "a98a270a7ab126bc", + "gsd-core/references/execute-phase-between-wave-reset.md": "3ad96ca0f7fee37e", + "gsd-core/references/execute-phase-context-guard.md": "982006c1f3364242", + "gsd-core/references/execute-phase-wave-guard.md": "de9ac22cead4cfd8", + "gsd-core/references/executor-examples.md": "ba59243ed45c8ab1", + "gsd-core/references/few-shot-examples/plan-checker.md": "2574808188ac9de4", + "gsd-core/references/few-shot-examples/verifier.md": "5badee4560b14ae8", + "gsd-core/references/gate-prompts.md": "e69f5993ab944d80", + "gsd-core/references/gates.md": "7dc9fd3a3d6217c6", + "gsd-core/references/git-integration.md": "77bf9dff38b2c9d4", + "gsd-core/references/git-planning-commit.md": "f897a15ebfc3f5a7", + "gsd-core/references/ios-scaffold.md": "5ef0cb7e0fac891f", + "gsd-core/references/loop-hook-dispatch.md": "32e5dfb4dba76987", + "gsd-core/references/mandatory-initial-read.md": "fe59abce693717cf", + "gsd-core/references/model-profile-resolution.md": "f32bb05102839767", + "gsd-core/references/model-profiles.md": "d3513252c5de5465", + "gsd-core/references/mvp-concepts.md": "72f7e8b1f8ae7118", + "gsd-core/references/phase-argument-parsing.md": "e5bbb985f3bc3e34", + "gsd-core/references/planner-antipatterns.md": "7ed54ec1e2cc54ac", + "gsd-core/references/planner-chunked.md": "79fe674221e738e6", + "gsd-core/references/planner-gap-closure.md": "76bee257911413e7", + "gsd-core/references/planner-graphify-auto-update.md": "6aeffd9097e25e22", + "gsd-core/references/planner-guidance.md": "96486cac2f7885e6", + "gsd-core/references/planner-human-verify-mode.md": "56d05e841630b3f4", + "gsd-core/references/planner-interface-context.md": "b28fa3da6ae739a8", + "gsd-core/references/planner-load-graph-context.md": "ca7a7af3f35ae61b", + "gsd-core/references/planner-mvp-mode.md": "ec33050db81101a8", + "gsd-core/references/planner-reviews.md": "dda0193a0fbd4947", + "gsd-core/references/planner-revision.md": "86ba8a511f081f05", + "gsd-core/references/planner-source-audit.md": "7de5bdb07232ce0b", + "gsd-core/references/planning-config.md": "dc492b7de6203355", + "gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json": "f10df472f2846cc6", + "gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json": "31e8a781eeffe020", + "gsd-core/references/prohibition-probe-fixtures/03-multi-prohibition/expected.json": "70a532a7cc1b6ae8", + "gsd-core/references/prohibition-probe.md": "605dc3f5a118ff3b", + "gsd-core/references/project-skills-discovery.md": "c155e03dce8dc3c2", + "gsd-core/references/questioning.md": "faac32813d1735bd", + "gsd-core/references/research-documentation-lookup.md": "c070007d1d72ab71", + "gsd-core/references/research-philosophy.md": "62930e66cc979c1a", + "gsd-core/references/research-verification-protocol.md": "9c38c9d9a687e679", + "gsd-core/references/revision-loop.md": "e55ff32dd98c63df", + "gsd-core/references/scout-codebase.md": "ba266ecc18fbf172", + "gsd-core/references/security-asvs-levels.md": "4774fac3b94b6ca8", + "gsd-core/references/skeleton-template.md": "528691d1f0efa878", + "gsd-core/references/sketch-interactivity.md": "7d982fe877e1e1cc", + "gsd-core/references/sketch-theme-system.md": "33e2e96e450456f8", + "gsd-core/references/sketch-tooling.md": "df6c4f24c1c27611", + "gsd-core/references/sketch-variant-patterns.md": "66c197aa4fb52810", + "gsd-core/references/spidr-splitting.md": "074ac154c0e4f906", + "gsd-core/references/tdd.md": "e4708ede157478b6", + "gsd-core/references/thinking-models-debug.md": "2da61022b16c4e7c", + "gsd-core/references/thinking-models-execution.md": "dcc650a8b5f3e049", + "gsd-core/references/thinking-models-planning.md": "7e19462313fa028f", + "gsd-core/references/thinking-models-research.md": "5f6bf3f3b889c6e4", + "gsd-core/references/thinking-models-verification.md": "a71a933d51ca3d8d", + "gsd-core/references/thinking-partner.md": "41069529ef776e39", + "gsd-core/references/ui-brand.md": "48717bcfcd63bd27", + "gsd-core/references/universal-anti-patterns.md": "6a1245050b21df01", + "gsd-core/references/untrusted-input-boundary.md": "d33b80d4d348599a", + "gsd-core/references/user-profiling.md": "b50416fe57c1b321", + "gsd-core/references/user-story-template.md": "0cc50e06a144ff8a", + "gsd-core/references/verification-overrides.md": "8213de9bd62283b6", + "gsd-core/references/verification-patterns.md": "72f4d4814fdae9a8", + "gsd-core/references/verify-mvp-mode.md": "534bdc7f2432903a", + "gsd-core/references/workstream-flag.md": "ca99ca79e716f0f5", + "gsd-core/references/worktree-branch-check.md": "21d9c31bf6542b93", + "gsd-core/references/worktree-path-safety.md": "3c8d74756f9b16a8", + "gsd-core/templates/AI-SPEC.md": "efa1f8354bd3a24b", + "gsd-core/templates/DEBUG.md": "a13470b82b1935e7", + "gsd-core/templates/README.md": "93d3426fc64e2c12", + "gsd-core/templates/SECURITY.md": "b628f7f1c6d2328f", + "gsd-core/templates/UAT.md": "9e296471b97ebcec", + "gsd-core/templates/UI-SPEC.md": "20ca56a4e3e21f01", + "gsd-core/templates/VALIDATION.md": "f53e0ca061d3528e", + "gsd-core/templates/claude-md.md": "d1d333e4b963c0d2", + "gsd-core/templates/codebase/architecture.md": "6be88214162fdd89", + "gsd-core/templates/codebase/concerns.md": "efa26d1fb5132f25", + "gsd-core/templates/codebase/conventions.md": "c2e07698dad6b364", + "gsd-core/templates/codebase/integrations.md": "39bd23c71eedd564", + "gsd-core/templates/codebase/stack.md": "116e7e67dd87ddec", + "gsd-core/templates/codebase/structure.md": "222997133232a6f5", + "gsd-core/templates/codebase/testing.md": "76abff7f2050c9ea", + "gsd-core/templates/config.json": "1473dff11272d3c4", + "gsd-core/templates/context.md": "69b01e7909ea3f66", + "gsd-core/templates/continue-here.md": "f522a51b6895fba8", + "gsd-core/templates/copilot-instructions.md": "d2872ac579934f0a", + "gsd-core/templates/debug-subagent-prompt.md": "920656683dedb869", + "gsd-core/templates/dev-preferences.md": "88d0a65ec0993a3a", + "gsd-core/templates/discovery.md": "9a0e0935cc825dbc", + "gsd-core/templates/discussion-log.md": "cac1b48ec0f4dcb8", + "gsd-core/templates/milestone-archive.md": "591b6decdc0c0e51", + "gsd-core/templates/milestone.md": "74d2f750ae9f4a9c", + "gsd-core/templates/phase-prompt.md": "b811bf951092df2f", + "gsd-core/templates/planner-subagent-prompt.md": "ebf29dbb27042370", + "gsd-core/templates/project.md": "ab4af6becca2bb23", + "gsd-core/templates/requirements.md": "a44de4c2f146e473", + "gsd-core/templates/research-project/ARCHITECTURE.md": "746b9ef791d758b0", + "gsd-core/templates/research-project/FEATURES.md": "f2b800de5df91b0f", + "gsd-core/templates/research-project/PITFALLS.md": "3ef75fa768422eec", + "gsd-core/templates/research-project/STACK.md": "82c85799ac4dd344", + "gsd-core/templates/research-project/SUMMARY.md": "dceb2f346388839d", + "gsd-core/templates/research.md": "88ce0920417091d0", + "gsd-core/templates/retrospective.md": "03981e30dd760103", + "gsd-core/templates/roadmap.md": "e4e35a9eb5dd4d4f", + "gsd-core/templates/spec.md": "26d55bce940f0288", + "gsd-core/templates/state.md": "4d123aa6cea167fe", + "gsd-core/templates/summary-complex.md": "a5e40574fd8894dc", + "gsd-core/templates/summary-minimal.md": "7d09b5e709e2e67c", + "gsd-core/templates/summary-standard.md": "e8d9cf4a8377cdff", + "gsd-core/templates/summary.md": "23c40f6503b3ea98", + "gsd-core/templates/user-profile.md": "20749f23e4c413fc", + "gsd-core/templates/user-setup.md": "78b7d718b6e8d67c", + "gsd-core/templates/verification-report.md": "dd5faa6254183731", + "gsd-core/workflows/_runtime-launcher.snippet.sh": "c0994e00f976dbfb", + "gsd-core/workflows/add-backlog.md": "1153d35058e3e24f", + "gsd-core/workflows/add-phase.md": "b98c2fc51cd123ba", + "gsd-core/workflows/add-tests.md": "7ec84770cbd9b850", + "gsd-core/workflows/add-todo.md": "a71015ef950e905a", + "gsd-core/workflows/ai-integration-phase.md": "56a079a218d9e0ee", + "gsd-core/workflows/analyze-dependencies.md": "52942af10f140717", + "gsd-core/workflows/audit-fix.md": "eaead7ea85761e5b", + "gsd-core/workflows/audit-milestone.md": "fe7185bb70eafdb9", + "gsd-core/workflows/audit-uat.md": "ca6f1f0ef174f793", + "gsd-core/workflows/autonomous.md": "7cc800bc9a5bfc8a", + "gsd-core/workflows/check-todos.md": "34b3034a31e7dbe5", + "gsd-core/workflows/cleanup.md": "e73b3969f5c10dcf", + "gsd-core/workflows/code-review-fix.md": "b442c670f8c1fe93", + "gsd-core/workflows/code-review.md": "1bfa3bf542e30c5e", + "gsd-core/workflows/complete-milestone.md": "262c6bf1691b1ec9", + "gsd-core/workflows/debug.md": "1c137aaa9ffac0dc", + "gsd-core/workflows/diagnose-issues.md": "dfd26bce63e52b00", + "gsd-core/workflows/discovery-phase.md": "100b931f0f40bf1c", + "gsd-core/workflows/discuss-phase-assumptions.md": "5e26c16d4be817a1", + "gsd-core/workflows/discuss-phase-power.md": "0841f7dc6e9a054a", + "gsd-core/workflows/discuss-phase.md": "3eeeda447a9dd3b5", + "gsd-core/workflows/discuss-phase/modes/advisor.md": "d0bcd7bafe7c9a91", + "gsd-core/workflows/discuss-phase/modes/all.md": "6c456679da748bdb", + "gsd-core/workflows/discuss-phase/modes/analyze.md": "da0788f3be7f8105", + "gsd-core/workflows/discuss-phase/modes/auto.md": "057b13a030f0d14d", + "gsd-core/workflows/discuss-phase/modes/batch.md": "390f2c1fff33c963", + "gsd-core/workflows/discuss-phase/modes/chain.md": "ca3cc1ddfd19cd9d", + "gsd-core/workflows/discuss-phase/modes/default.md": "4ada4fe332c5c985", + "gsd-core/workflows/discuss-phase/modes/power.md": "fdc7a728eaaa2261", + "gsd-core/workflows/discuss-phase/modes/text.md": "5590f1f1a4554ad3", + "gsd-core/workflows/discuss-phase/templates/checkpoint.json": "e3bc3dca49db59eb", + "gsd-core/workflows/discuss-phase/templates/context.md": "6cd929e989fe2b0f", + "gsd-core/workflows/discuss-phase/templates/discussion-log.md": "cd5d01c9ef84ab73", + "gsd-core/workflows/do.md": "cd4f183e89f95c44", + "gsd-core/workflows/docs-update.md": "4f0207fdaab85b1d", + "gsd-core/workflows/edit-phase.md": "e5624ac6e3f8bef5", + "gsd-core/workflows/eval-review.md": "f28d703285d611d2", + "gsd-core/workflows/execute-phase.md": "dcc50bd78c0047ea", + "gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md": "8898e0ea533cc643", + "gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md": "7ebb7d1af6082028", + "gsd-core/workflows/execute-phase/steps/post-merge-gate.md": "abd2aca069c04a80", + "gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md": "be84efbd71e1513e", + "gsd-core/workflows/execute-plan.md": "73a7c87e975fa393", + "gsd-core/workflows/explore.md": "11523b829cc67c29", + "gsd-core/workflows/extract-learnings.md": "a2e4836abdeb1fdb", + "gsd-core/workflows/fast.md": "94136fb570d20a9d", + "gsd-core/workflows/forensics.md": "76bea6e254652c2c", + "gsd-core/workflows/graduation.md": "d00ac4dfe7e1cc43", + "gsd-core/workflows/health.md": "ae213b0d090641a2", + "gsd-core/workflows/help.md": "5d040504b9ab35e3", + "gsd-core/workflows/help/modes/brief.md": "5fe077fbbdb5d9f7", + "gsd-core/workflows/help/modes/default.md": "cc8ef97de634c995", + "gsd-core/workflows/help/modes/full.md": "d15ffdcd90ff06b1", + "gsd-core/workflows/help/modes/topic.md": "d7c60d488810fbd6", + "gsd-core/workflows/import.md": "c740cfabacf7b70a", + "gsd-core/workflows/inbox.md": "41371f790ef06101", + "gsd-core/workflows/ingest-docs.md": "1ee3890d42d95576", + "gsd-core/workflows/insert-phase.md": "621f9e75725542b1", + "gsd-core/workflows/list-phase-assumptions.md": "53dd4b69536c1bc3", + "gsd-core/workflows/list-seeds.md": "0a466d04df300854", + "gsd-core/workflows/list-workspaces.md": "0409cb5e3859964f", + "gsd-core/workflows/manager.md": "fd5816b3fc7a68de", + "gsd-core/workflows/map-codebase.md": "582bddef590de9c6", + "gsd-core/workflows/milestone-summary.md": "f956783f7d063963", + "gsd-core/workflows/mvp-phase.md": "6cb2d9d97bd239b3", + "gsd-core/workflows/new-milestone.md": "7fbdbad9b2c38215", + "gsd-core/workflows/new-project.md": "72b2c17e9f9cb79d", + "gsd-core/workflows/new-workspace.md": "226482083477bc43", + "gsd-core/workflows/next.md": "48bd4b5d54f0d1f8", + "gsd-core/workflows/node-repair.md": "07a1628e5a1ff96b", + "gsd-core/workflows/note.md": "95199087905ba3e6", + "gsd-core/workflows/pause-work.md": "7bcbdf27ba957c8b", + "gsd-core/workflows/plan-milestone-gaps.md": "02fee851c82e3b25", + "gsd-core/workflows/plan-phase.md": "b2904cffb7ff0127", + "gsd-core/workflows/plan-review-convergence.md": "57c284df29121220", + "gsd-core/workflows/plant-seed.md": "339890021123e017", + "gsd-core/workflows/pr-branch.md": "f2a35833fe784a53", + "gsd-core/workflows/profile-user.md": "abf4520bd9975305", + "gsd-core/workflows/progress.md": "1caa7b2be914c717", + "gsd-core/workflows/quick.md": "7d1e7a62213fa190", + "gsd-core/workflows/reapply-patches.md": "7915e40411490bbe", + "gsd-core/workflows/remove-phase.md": "d030f80ca0df4fa9", + "gsd-core/workflows/remove-workspace.md": "9f5589b4b3a0639a", + "gsd-core/workflows/resume-project.md": "b18b51fd15cbce95", + "gsd-core/workflows/review.md": "18ab093986814bb3", + "gsd-core/workflows/scan.md": "54ff1ff60041d065", + "gsd-core/workflows/secure-phase.md": "a2f6a03034444f14", + "gsd-core/workflows/session-report.md": "2e5b1205324ddefa", + "gsd-core/workflows/settings-advanced.md": "d1fb1c177d872b70", + "gsd-core/workflows/settings-integrations.md": "9753c48c7826cfdd", + "gsd-core/workflows/settings.md": "82536480fe362e82", + "gsd-core/workflows/ship.md": "dfbf2069ce4b276d", + "gsd-core/workflows/sketch-wrap-up.md": "067e2654f3fdaba2", + "gsd-core/workflows/sketch.md": "e1a544c83bb6e5be", + "gsd-core/workflows/spec-phase.md": "0d67fa7de33933c0", + "gsd-core/workflows/spike-wrap-up.md": "99a9e23d1c6cf66e", + "gsd-core/workflows/spike.md": "c9482514e665bcac", + "gsd-core/workflows/stats.md": "01c24349370a0e6d", + "gsd-core/workflows/sync-skills.md": "b505e6f8331c0918", + "gsd-core/workflows/thread.md": "c26ca43fdf928d46", + "gsd-core/workflows/transition.md": "69143573741c52e4", + "gsd-core/workflows/ui-phase.md": "bb5167948032872e", + "gsd-core/workflows/ui-review.md": "d256bec482e67af8", + "gsd-core/workflows/ultraplan-phase.md": "66c17ad2e555e262", + "gsd-core/workflows/undo.md": "993bb0a31edca7f8", + "gsd-core/workflows/update.md": "51c3af33474bdc24", + "gsd-core/workflows/validate-phase.md": "2d998cb78c918d08", + "gsd-core/workflows/verify-phase.md": "968d569ea4ef377f", + "gsd-core/workflows/verify-work.md": "1265e07f2c74a218", + "hooks/gsd-check-update-worker.js": "7a3eba8c1c166dd2", + "hooks/gsd-check-update.js": "c1c78326299f6eae", + "hooks/gsd-config-reload.js": "17bf778d432b3d2a", + "hooks/gsd-context-monitor.js": "c7793e66ce76aaeb", + "hooks/gsd-cursor-post-tool.js": "d61ee04f6ee7858c", + "hooks/gsd-cursor-session-start.js": "148b8ec4e2c97f00", + "hooks/gsd-ensure-canonical-path.js": "a19947cac42002d4", + "hooks/gsd-graphify-update.sh": "396ebda3c6705dc9", + "hooks/gsd-phase-boundary.sh": "6aa3ba9af3d465d9", + "hooks/gsd-prompt-guard.js": "4b08c2dce0233e2d", + "hooks/gsd-read-guard.js": "b602f88f046a7551", + "hooks/gsd-read-injection-scanner.js": "f8db0daa41afe13b", + "hooks/gsd-session-state.sh": "b1496e6a5204a6df", + "hooks/gsd-statusline.js": "c238ad773bacae32", + "hooks/gsd-update-banner.js": "74817c820b7a4ec1", + "hooks/gsd-validate-commit.sh": "14d3d966c74dc310", + "hooks/gsd-workflow-guard.js": "e22b9fb57f0e64f6", + "hooks/gsd-worktree-path-guard.js": "134c7919c4cbc78e", + "hooks/lib/git-cmd.js": "268ba15992ca0b23", + "hooks/lib/gsd-graphify-rebuild.sh": "66af89601074d2a9", + "hooks/managed-hooks-registry.cjs": "9372d0d21a2c4298", + "package.json": "dbf8353f77358bc1", + "scripts/changeset/README.md": "86ff89331dfd94b2", + "scripts/changeset/cli.cjs": "68f92a344b199271", + "scripts/changeset/github-release-notes.cjs": "795677f0c009b132", + "scripts/changeset/lint.cjs": "0066faed159154f0", + "scripts/changeset/new.cjs": "4991e21fd17f5541", + "scripts/changeset/parse.cjs": "f9a949cbcab56445", + "scripts/changeset/render.cjs": "e47bc3e1587c3cae", + "scripts/changeset/serialize.cjs": "ac0b8fe6f87cdb0e", + "scripts/fix-slash-commands.cjs": "0519742531ff3529", + "scripts/lib/allowlist-ratchet.cjs": "ffaceaac3efc2660", + "scripts/lib/cli-exit.cjs": "612d0c372c75b7e7" +} diff --git a/tests/fixtures/golden-install-parity/hermes.json b/tests/fixtures/golden-install-parity/hermes.json new file mode 100644 index 000000000..0971e3536 --- /dev/null +++ b/tests/fixtures/golden-install-parity/hermes.json @@ -0,0 +1,405 @@ +{ + ".gsd-profile": "0e716a5fef4e6dc1", + ".gsd/defaults.json": "560664b045e645cb", + "agents/gsd-advisor-researcher.md": "5a0c65ccc25ddfbe", + "agents/gsd-ai-researcher.md": "97fdf624a1c6c324", + "agents/gsd-assumptions-analyzer.md": "a8982fc704f7ec81", + "agents/gsd-code-fixer.md": "434ebbd947f07070", + "agents/gsd-code-reviewer.md": "1652918c20f42662", + "agents/gsd-codebase-mapper.md": "2d50f93ac1c0d3d5", + "agents/gsd-debug-session-manager.md": "6dd3555fa4ea8e93", + "agents/gsd-debugger.md": "0b4e9ac3601a6fb5", + "agents/gsd-doc-classifier.md": "5de2a66c751b2d58", + "agents/gsd-doc-synthesizer.md": "8e6fb8220f777022", + "agents/gsd-doc-verifier.md": "d3d8457bdd44bf10", + "agents/gsd-doc-writer.md": "82672f7a8a3ac381", + "agents/gsd-domain-researcher.md": "d4c07520f650ffd5", + "agents/gsd-eval-auditor.md": "95459532da30ab80", + "agents/gsd-eval-planner.md": "03448fc9c5774b56", + "agents/gsd-executor.md": "060d166bdb2a1ffc", + "agents/gsd-framework-selector.md": "ea9981d65d6b3429", + "agents/gsd-integration-checker.md": "342ddf68d898df2c", + "agents/gsd-intel-updater.md": "03e143f85ab105ed", + "agents/gsd-mempalace-curator.md": "576b8d0db51bc462", + "agents/gsd-nyquist-auditor.md": "354bd95e897a2946", + "agents/gsd-pattern-mapper.md": "cea092600aeb3978", + "agents/gsd-phase-researcher.md": "8d871d027ca41ec0", + "agents/gsd-plan-checker.md": "73ac6ffdef0c20cb", + "agents/gsd-planner.md": "1a60305d2a5daa3e", + "agents/gsd-project-researcher.md": "cc7e3bcb4f8ee30c", + "agents/gsd-research-synthesizer.md": "698d8c2f680b5a8d", + "agents/gsd-roadmapper.md": "3c19e19c06a1ba96", + "agents/gsd-security-auditor.md": "101f145e5f9e5724", + "agents/gsd-ui-auditor.md": "145b9c807012374c", + "agents/gsd-ui-checker.md": "32b2605c7254f959", + "agents/gsd-ui-researcher.md": "87cf9eebc37c4a37", + "agents/gsd-user-profiler.md": "ca3bf75581f211a0", + "agents/gsd-verifier.md": "4decc9b596090ca6", + "gsd-core/CHANGELOG.md": "e141e3fb369ff712", + "gsd-core/VERSION": "562368b20a64be95", + "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", + "gsd-core/bin/gsd-tools.cjs": "b7968e3e3af00249", + "gsd-core/bin/gsd_run": "62d9b647ede212e6", + "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", + "gsd-core/bin/shared/config-schema.manifest.json": "67e4addbfd248a7c", + "gsd-core/bin/shared/model-catalog.json": "dbe26e683236d8c6", + "gsd-core/bin/shared/runtime-aliases.manifest.json": "f6c8b3af10dcfdc4", + "gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904", + "gsd-core/contexts/dev.md": "dcb0de9dce33cf41", + "gsd-core/contexts/research.md": "b3285d8e7209cc3b", + "gsd-core/contexts/review.md": "dc578fdd74bbea11", + "gsd-core/references/agent-contracts.md": "ff65e633c656c0d2", + "gsd-core/references/ai-evals.md": "b5afa786b938671e", + "gsd-core/references/ai-frameworks.md": "f827de93dde124eb", + "gsd-core/references/artifact-types.md": "8bd01fd75a2ba70e", + "gsd-core/references/autonomous-smart-discuss.md": "2fc710cde0ec7785", + "gsd-core/references/checkpoints.md": "db8a7425ed808e24", + "gsd-core/references/common-bug-patterns.md": "780145be56352626", + "gsd-core/references/context-budget.md": "b93e9f47aa1b1753", + "gsd-core/references/continuation-format.md": "580287399ad3ba68", + "gsd-core/references/debugger-philosophy.md": "0466f95a3d6bfcd3", + "gsd-core/references/decimal-phase-calculation.md": "46b5ba045852c474", + "gsd-core/references/doc-conflict-engine.md": "883d0a1b9d9ff96e", + "gsd-core/references/domain-probes.md": "762b965e84035b72", + "gsd-core/references/edge-probe-fixtures/01-round-half-even/expected-coverage.json": "72d1e29cedc854ec", + "gsd-core/references/edge-probe-fixtures/01-round-half-even/requirements.json": "fbc1b355d8625eeb", + "gsd-core/references/edge-probe-fixtures/02-merge-intervals/expected-coverage.json": "fad67dcc8294f6da", + "gsd-core/references/edge-probe-fixtures/02-merge-intervals/requirements.json": "30a78ee9ce3473ea", + "gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/expected-coverage.json": "66dd60957fee45f0", + "gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/requirements.json": "47fca61f076835fa", + "gsd-core/references/edge-probe-fixtures/04-money-rounding/expected-coverage.json": "72d1e29cedc854ec", + "gsd-core/references/edge-probe-fixtures/04-money-rounding/requirements.json": "80f04f5c04fb24cf", + "gsd-core/references/edge-probe-fixtures/05-list-dedupe/expected-coverage.json": "fad67dcc8294f6da", + "gsd-core/references/edge-probe-fixtures/05-list-dedupe/requirements.json": "d38147adb0e5b342", + "gsd-core/references/edge-probe-fixtures/06-resolved-mixed/expected-coverage.json": "bc552c01939bf4f8", + "gsd-core/references/edge-probe-fixtures/06-resolved-mixed/requirements.json": "30a78ee9ce3473ea", + "gsd-core/references/edge-probe-fixtures/06-resolved-mixed/resolutions.json": "688ec62c13e08afe", + "gsd-core/references/edge-probe.md": "5687eba25a078561", + "gsd-core/references/execute-mvp-tdd.md": "a98a270a7ab126bc", + "gsd-core/references/execute-phase-between-wave-reset.md": "2c5cbdbc73cb053e", + "gsd-core/references/execute-phase-context-guard.md": "a5a1058d35806a8e", + "gsd-core/references/execute-phase-wave-guard.md": "b13d8860a76b7a41", + "gsd-core/references/executor-examples.md": "ba59243ed45c8ab1", + "gsd-core/references/few-shot-examples/plan-checker.md": "2574808188ac9de4", + "gsd-core/references/few-shot-examples/verifier.md": "5badee4560b14ae8", + "gsd-core/references/gate-prompts.md": "099c8d52e3562336", + "gsd-core/references/gates.md": "7dc9fd3a3d6217c6", + "gsd-core/references/git-integration.md": "12d23fcbaa7fcf06", + "gsd-core/references/git-planning-commit.md": "f897a15ebfc3f5a7", + "gsd-core/references/ios-scaffold.md": "5ef0cb7e0fac891f", + "gsd-core/references/loop-hook-dispatch.md": "32e5dfb4dba76987", + "gsd-core/references/mandatory-initial-read.md": "fe59abce693717cf", + "gsd-core/references/model-profile-resolution.md": "f32bb05102839767", + "gsd-core/references/model-profiles.md": "2be36137de1ebb67", + "gsd-core/references/mvp-concepts.md": "3464783eaaef5c10", + "gsd-core/references/phase-argument-parsing.md": "e5bbb985f3bc3e34", + "gsd-core/references/planner-antipatterns.md": "7ed54ec1e2cc54ac", + "gsd-core/references/planner-chunked.md": "79fe674221e738e6", + "gsd-core/references/planner-gap-closure.md": "76bee257911413e7", + "gsd-core/references/planner-graphify-auto-update.md": "1ed614dfba72f2a3", + "gsd-core/references/planner-guidance.md": "140b1266eeb096cb", + "gsd-core/references/planner-human-verify-mode.md": "6cfa61ca5f6c1879", + "gsd-core/references/planner-interface-context.md": "b28fa3da6ae739a8", + "gsd-core/references/planner-load-graph-context.md": "8f598e08696843c0", + "gsd-core/references/planner-mvp-mode.md": "ec33050db81101a8", + "gsd-core/references/planner-reviews.md": "da39eace09a10743", + "gsd-core/references/planner-revision.md": "86ba8a511f081f05", + "gsd-core/references/planner-source-audit.md": "7de5bdb07232ce0b", + "gsd-core/references/planning-config.md": "9b5f8ed49024cdbf", + "gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json": "f10df472f2846cc6", + "gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json": "31e8a781eeffe020", + "gsd-core/references/prohibition-probe-fixtures/03-multi-prohibition/expected.json": "70a532a7cc1b6ae8", + "gsd-core/references/prohibition-probe.md": "52872d5393ea9daf", + "gsd-core/references/project-skills-discovery.md": "0885d973eeac5234", + "gsd-core/references/questioning.md": "a8c988cab05f4651", + "gsd-core/references/research-documentation-lookup.md": "c070007d1d72ab71", + "gsd-core/references/research-philosophy.md": "62930e66cc979c1a", + "gsd-core/references/research-verification-protocol.md": "9c38c9d9a687e679", + "gsd-core/references/revision-loop.md": "e55ff32dd98c63df", + "gsd-core/references/scout-codebase.md": "ba266ecc18fbf172", + "gsd-core/references/security-asvs-levels.md": "4774fac3b94b6ca8", + "gsd-core/references/skeleton-template.md": "528691d1f0efa878", + "gsd-core/references/sketch-interactivity.md": "7d982fe877e1e1cc", + "gsd-core/references/sketch-theme-system.md": "33e2e96e450456f8", + "gsd-core/references/sketch-tooling.md": "df6c4f24c1c27611", + "gsd-core/references/sketch-variant-patterns.md": "66c197aa4fb52810", + "gsd-core/references/spidr-splitting.md": "074ac154c0e4f906", + "gsd-core/references/tdd.md": "e4708ede157478b6", + "gsd-core/references/thinking-models-debug.md": "2da61022b16c4e7c", + "gsd-core/references/thinking-models-execution.md": "dcc650a8b5f3e049", + "gsd-core/references/thinking-models-planning.md": "7e19462313fa028f", + "gsd-core/references/thinking-models-research.md": "5f6bf3f3b889c6e4", + "gsd-core/references/thinking-models-verification.md": "a71a933d51ca3d8d", + "gsd-core/references/thinking-partner.md": "827c1badf3e6df41", + "gsd-core/references/ui-brand.md": "48717bcfcd63bd27", + "gsd-core/references/universal-anti-patterns.md": "6a1245050b21df01", + "gsd-core/references/untrusted-input-boundary.md": "d33b80d4d348599a", + "gsd-core/references/user-profiling.md": "b50416fe57c1b321", + "gsd-core/references/user-story-template.md": "0cc50e06a144ff8a", + "gsd-core/references/verification-overrides.md": "a3e2d5166d16a37b", + "gsd-core/references/verification-patterns.md": "72f4d4814fdae9a8", + "gsd-core/references/verify-mvp-mode.md": "534bdc7f2432903a", + "gsd-core/references/workstream-flag.md": "50ddf79d58950730", + "gsd-core/references/worktree-branch-check.md": "5d79e26e3b51d9ae", + "gsd-core/references/worktree-path-safety.md": "fa79fcdff2aaa5f0", + "gsd-core/templates/AI-SPEC.md": "24df5fe5ba34e367", + "gsd-core/templates/DEBUG.md": "57bd61bfd1d98e7e", + "gsd-core/templates/README.md": "89560317a9097a05", + "gsd-core/templates/SECURITY.md": "b628f7f1c6d2328f", + "gsd-core/templates/UAT.md": "68d32d1fea14e184", + "gsd-core/templates/UI-SPEC.md": "20ca56a4e3e21f01", + "gsd-core/templates/VALIDATION.md": "6144951011cdca57", + "gsd-core/templates/claude-md.md": "7c778398f79e25a3", + "gsd-core/templates/codebase/architecture.md": "6be88214162fdd89", + "gsd-core/templates/codebase/concerns.md": "efa26d1fb5132f25", + "gsd-core/templates/codebase/conventions.md": "c2e07698dad6b364", + "gsd-core/templates/codebase/integrations.md": "39bd23c71eedd564", + "gsd-core/templates/codebase/stack.md": "116e7e67dd87ddec", + "gsd-core/templates/codebase/structure.md": "6f968b6bb6e1982f", + "gsd-core/templates/codebase/testing.md": "76abff7f2050c9ea", + "gsd-core/templates/config.json": "1473dff11272d3c4", + "gsd-core/templates/context.md": "69b01e7909ea3f66", + "gsd-core/templates/continue-here.md": "f522a51b6895fba8", + "gsd-core/templates/copilot-instructions.md": "aea34bc52ff548ea", + "gsd-core/templates/debug-subagent-prompt.md": "8c18a89e25929d8e", + "gsd-core/templates/dev-preferences.md": "95048a71063d980b", + "gsd-core/templates/discovery.md": "e4ab738326eb70e0", + "gsd-core/templates/discussion-log.md": "cac1b48ec0f4dcb8", + "gsd-core/templates/milestone-archive.md": "591b6decdc0c0e51", + "gsd-core/templates/milestone.md": "74d2f750ae9f4a9c", + "gsd-core/templates/phase-prompt.md": "213ccd947451ff2b", + "gsd-core/templates/planner-subagent-prompt.md": "6c9f1b23ee3dc05f", + "gsd-core/templates/project.md": "1f687c7a88381080", + "gsd-core/templates/requirements.md": "a44de4c2f146e473", + "gsd-core/templates/research-project/ARCHITECTURE.md": "746b9ef791d758b0", + "gsd-core/templates/research-project/FEATURES.md": "f2b800de5df91b0f", + "gsd-core/templates/research-project/PITFALLS.md": "3ef75fa768422eec", + "gsd-core/templates/research-project/STACK.md": "82c85799ac4dd344", + "gsd-core/templates/research-project/SUMMARY.md": "dceb2f346388839d", + "gsd-core/templates/research.md": "fa6dfb2ff2e8d273", + "gsd-core/templates/retrospective.md": "03981e30dd760103", + "gsd-core/templates/roadmap.md": "e4e35a9eb5dd4d4f", + "gsd-core/templates/spec.md": "7dc900c355098d8b", + "gsd-core/templates/state.md": "73e424b8c70b765c", + "gsd-core/templates/summary-complex.md": "a5e40574fd8894dc", + "gsd-core/templates/summary-minimal.md": "7d09b5e709e2e67c", + "gsd-core/templates/summary-standard.md": "e8d9cf4a8377cdff", + "gsd-core/templates/summary.md": "23c40f6503b3ea98", + "gsd-core/templates/user-profile.md": "20749f23e4c413fc", + "gsd-core/templates/user-setup.md": "78b7d718b6e8d67c", + "gsd-core/templates/verification-report.md": "dd5faa6254183731", + "gsd-core/workflows/_runtime-launcher.snippet.sh": "c0994e00f976dbfb", + "gsd-core/workflows/add-backlog.md": "73e5ca92d9456f53", + "gsd-core/workflows/add-phase.md": "3683d413f223f138", + "gsd-core/workflows/add-tests.md": "49524ea9e54535b6", + "gsd-core/workflows/add-todo.md": "cc4a1b90384ad7b9", + "gsd-core/workflows/ai-integration-phase.md": "006583b7f234af02", + "gsd-core/workflows/analyze-dependencies.md": "77aff48f97fa6f1c", + "gsd-core/workflows/audit-fix.md": "5aded4cc2682502b", + "gsd-core/workflows/audit-milestone.md": "c36d1c46d4aecca7", + "gsd-core/workflows/audit-uat.md": "5e93ef61f91ad0c0", + "gsd-core/workflows/autonomous.md": "7a482d2afb346e57", + "gsd-core/workflows/check-todos.md": "8807759cc4df46c0", + "gsd-core/workflows/cleanup.md": "6380894e67d2435d", + "gsd-core/workflows/code-review-fix.md": "5090840d834ec31a", + "gsd-core/workflows/code-review.md": "29046f7fa6a9d40a", + "gsd-core/workflows/complete-milestone.md": "96db03239ff95208", + "gsd-core/workflows/debug.md": "5840a39f14a029c4", + "gsd-core/workflows/diagnose-issues.md": "b350281a56ee5832", + "gsd-core/workflows/discovery-phase.md": "6161c60d752d0058", + "gsd-core/workflows/discuss-phase-assumptions.md": "c158386c6cd92bd5", + "gsd-core/workflows/discuss-phase-power.md": "290c0d83d783f9f6", + "gsd-core/workflows/discuss-phase.md": "f6aa630f777d3006", + "gsd-core/workflows/discuss-phase/modes/advisor.md": "432c381a366be630", + "gsd-core/workflows/discuss-phase/modes/all.md": "fa70d79066562e54", + "gsd-core/workflows/discuss-phase/modes/analyze.md": "da0788f3be7f8105", + "gsd-core/workflows/discuss-phase/modes/auto.md": "524aaa69ae3a9ee5", + "gsd-core/workflows/discuss-phase/modes/batch.md": "6946597770e2d448", + "gsd-core/workflows/discuss-phase/modes/chain.md": "1a6400d7663d7643", + "gsd-core/workflows/discuss-phase/modes/default.md": "67d1b67f61f03966", + "gsd-core/workflows/discuss-phase/modes/power.md": "fdc7a728eaaa2261", + "gsd-core/workflows/discuss-phase/modes/text.md": "e6d930e2b6769e49", + "gsd-core/workflows/discuss-phase/templates/checkpoint.json": "e3bc3dca49db59eb", + "gsd-core/workflows/discuss-phase/templates/context.md": "6cd929e989fe2b0f", + "gsd-core/workflows/discuss-phase/templates/discussion-log.md": "1bbd7703f11128e1", + "gsd-core/workflows/do.md": "a1a891c1d5d58d67", + "gsd-core/workflows/docs-update.md": "c9df0f7df1ebec79", + "gsd-core/workflows/edit-phase.md": "57d807e21fe70355", + "gsd-core/workflows/eval-review.md": "d16eebac88386c05", + "gsd-core/workflows/execute-phase.md": "168863795989881a", + "gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md": "b890bafa4a0c8bd3", + "gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md": "7ebb7d1af6082028", + "gsd-core/workflows/execute-phase/steps/post-merge-gate.md": "e34415dec69b8432", + "gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md": "be84efbd71e1513e", + "gsd-core/workflows/execute-plan.md": "eb5de66a1cb41271", + "gsd-core/workflows/explore.md": "1c0f3ced293df114", + "gsd-core/workflows/extract-learnings.md": "c6cfb696d3bb51c7", + "gsd-core/workflows/fast.md": "54fe93778b45a7eb", + "gsd-core/workflows/forensics.md": "b381b789a871507d", + "gsd-core/workflows/graduation.md": "1fb19f28634dd55a", + "gsd-core/workflows/health.md": "32c1276656602ccb", + "gsd-core/workflows/help.md": "5d040504b9ab35e3", + "gsd-core/workflows/help/modes/brief.md": "2e923b3ed635d495", + "gsd-core/workflows/help/modes/default.md": "bb6876c976996dbd", + "gsd-core/workflows/help/modes/full.md": "eba05fd7c355b65f", + "gsd-core/workflows/help/modes/topic.md": "bd2e4cc8e460f5bd", + "gsd-core/workflows/import.md": "da7ca19f487c6b60", + "gsd-core/workflows/inbox.md": "91aac6360e1a8672", + "gsd-core/workflows/ingest-docs.md": "8adc1e35097abce2", + "gsd-core/workflows/insert-phase.md": "dad4479236245bd9", + "gsd-core/workflows/list-phase-assumptions.md": "2a6b6a5acfb7742c", + "gsd-core/workflows/list-seeds.md": "e4f9d7c12c162ef7", + "gsd-core/workflows/list-workspaces.md": "d3726de6b2eb40b4", + "gsd-core/workflows/manager.md": "c8690740c613f1a5", + "gsd-core/workflows/map-codebase.md": "23fae66ceef58e1c", + "gsd-core/workflows/milestone-summary.md": "5cc04210fea41834", + "gsd-core/workflows/mvp-phase.md": "5ce3c326ef0f6e27", + "gsd-core/workflows/new-milestone.md": "13f278af2e696d8b", + "gsd-core/workflows/new-project.md": "40c94cfb81bde99b", + "gsd-core/workflows/new-workspace.md": "af3397e97cec7d0b", + "gsd-core/workflows/next.md": "e044321fdd6b5e9c", + "gsd-core/workflows/node-repair.md": "07a1628e5a1ff96b", + "gsd-core/workflows/note.md": "42b66686b2c102cb", + "gsd-core/workflows/pause-work.md": "2c3abcaa1fa6d2e2", + "gsd-core/workflows/plan-milestone-gaps.md": "419744c1191354af", + "gsd-core/workflows/plan-phase.md": "7d6cbb6730d852d2", + "gsd-core/workflows/plan-review-convergence.md": "8ebcd05fcd5a0d15", + "gsd-core/workflows/plant-seed.md": "76379e2aeb18d53b", + "gsd-core/workflows/pr-branch.md": "79f4d93e31af9c49", + "gsd-core/workflows/profile-user.md": "8474c1e203be5c8c", + "gsd-core/workflows/progress.md": "173b166cd30fca13", + "gsd-core/workflows/quick.md": "29052a14c8dc71e0", + "gsd-core/workflows/reapply-patches.md": "7af45bb3f2fdd1b8", + "gsd-core/workflows/remove-phase.md": "f27cecd8c78008ae", + "gsd-core/workflows/remove-workspace.md": "977155e18b9df623", + "gsd-core/workflows/resume-project.md": "849f3d49d366ff13", + "gsd-core/workflows/review.md": "7dd0a479d2595ff0", + "gsd-core/workflows/scan.md": "cbde2b8b2b5fa5dd", + "gsd-core/workflows/secure-phase.md": "5fc8fbd5e217e48d", + "gsd-core/workflows/session-report.md": "2e5b1205324ddefa", + "gsd-core/workflows/settings-advanced.md": "26d73fcc5f68f3ad", + "gsd-core/workflows/settings-integrations.md": "ab490f2d5c7bbf34", + "gsd-core/workflows/settings.md": "9e35a3fedb0a8f53", + "gsd-core/workflows/ship.md": "3f6e6424d089d4d0", + "gsd-core/workflows/sketch-wrap-up.md": "eb5f0849f07479aa", + "gsd-core/workflows/sketch.md": "9a712af64fc2fff8", + "gsd-core/workflows/spec-phase.md": "79f136ab71edf595", + "gsd-core/workflows/spike-wrap-up.md": "79e23a81bb74bc95", + "gsd-core/workflows/spike.md": "80844a3c05339fdb", + "gsd-core/workflows/stats.md": "01289f444b66913d", + "gsd-core/workflows/sync-skills.md": "b505e6f8331c0918", + "gsd-core/workflows/thread.md": "7af5046e18c81ee9", + "gsd-core/workflows/transition.md": "adab3f53afd5d8aa", + "gsd-core/workflows/ui-phase.md": "dd213d91b5c258a5", + "gsd-core/workflows/ui-review.md": "83d6d3b1f26597ff", + "gsd-core/workflows/ultraplan-phase.md": "600377d719253f14", + "gsd-core/workflows/undo.md": "791e0bf96d9a057f", + "gsd-core/workflows/update.md": "472d4905fc8c5ce5", + "gsd-core/workflows/validate-phase.md": "067acd7aeed52c5b", + "gsd-core/workflows/verify-phase.md": "fecef63dcecc4104", + "gsd-core/workflows/verify-work.md": "aa0b4d000b15dbf1", + "hooks/gsd-check-update-worker.js": "80865e926e22623e", + "hooks/gsd-check-update.js": "57433c9f9b224e3e", + "hooks/gsd-config-reload.js": "ca99e7dc60a9815d", + "hooks/gsd-context-monitor.js": "f058fdb4b8b6c939", + "hooks/gsd-cursor-post-tool.js": "dd1b12f795de8d72", + "hooks/gsd-cursor-session-start.js": "a93095ac609a3ea6", + "hooks/gsd-ensure-canonical-path.js": "c49fb011c3b97f4a", + "hooks/gsd-graphify-update.sh": "396ebda3c6705dc9", + "hooks/gsd-phase-boundary.sh": "6aa3ba9af3d465d9", + "hooks/gsd-prompt-guard.js": "4b08c2dce0233e2d", + "hooks/gsd-read-guard.js": "474bc1800d34456f", + "hooks/gsd-read-injection-scanner.js": "2f32423c033ed5fd", + "hooks/gsd-session-state.sh": "b1496e6a5204a6df", + "hooks/gsd-statusline.js": "453cdf46bcf62368", + "hooks/gsd-update-banner.js": "d3228b9e674296b4", + "hooks/gsd-validate-commit.sh": "14d3d966c74dc310", + "hooks/gsd-workflow-guard.js": "6faa8f81812a8b5d", + "hooks/gsd-worktree-path-guard.js": "93bb15bf44b8c60d", + "hooks/lib/git-cmd.js": "268ba15992ca0b23", + "hooks/lib/gsd-graphify-rebuild.sh": "66af89601074d2a9", + "hooks/managed-hooks-registry.cjs": "2218a41c279720c2", + "package.json": "dbf8353f77358bc1", + "scripts/changeset/README.md": "86ff89331dfd94b2", + "scripts/changeset/cli.cjs": "68f92a344b199271", + "scripts/changeset/github-release-notes.cjs": "795677f0c009b132", + "scripts/changeset/lint.cjs": "0066faed159154f0", + "scripts/changeset/new.cjs": "4991e21fd17f5541", + "scripts/changeset/parse.cjs": "f9a949cbcab56445", + "scripts/changeset/render.cjs": "e47bc3e1587c3cae", + "scripts/changeset/serialize.cjs": "ac0b8fe6f87cdb0e", + "scripts/fix-slash-commands.cjs": "0519742531ff3529", + "scripts/lib/allowlist-ratchet.cjs": "ffaceaac3efc2660", + "scripts/lib/cli-exit.cjs": "612d0c372c75b7e7", + "skills/gsd/DESCRIPTION.md": "5f38d874c5b24402", + "skills/gsd/gsd-ns-context/SKILL.md": "151b2ba0fac3941c", + "skills/gsd/gsd-ns-context/skills/docs-update/SKILL.md": "df94f6ae46ec5795", + "skills/gsd/gsd-ns-context/skills/extract-learnings/SKILL.md": "52be8a979bef730c", + "skills/gsd/gsd-ns-context/skills/graphify/SKILL.md": "f7dec1c111fb100e", + "skills/gsd/gsd-ns-context/skills/map-codebase/SKILL.md": "ccd09679064252b4", + "skills/gsd/gsd-ns-context/skills/mempalace-capture/SKILL.md": "046f39c201d7365a", + "skills/gsd/gsd-ns-context/skills/mempalace-recall/SKILL.md": "e1a57c1fec64d5f9", + "skills/gsd/gsd-ns-ideate/SKILL.md": "2e3c4e56eff154e5", + "skills/gsd/gsd-ns-ideate/skills/capture/SKILL.md": "b7bc43f69fd76622", + "skills/gsd/gsd-ns-ideate/skills/explore/SKILL.md": "360b2e6f645495db", + "skills/gsd/gsd-ns-ideate/skills/sketch/SKILL.md": "64ca8f967e319f27", + "skills/gsd/gsd-ns-ideate/skills/spec-phase/SKILL.md": "83186cb129fdae50", + "skills/gsd/gsd-ns-ideate/skills/spike/SKILL.md": "ce57cb5dc6d2a4a6", + "skills/gsd/gsd-ns-manage/SKILL.md": "fac0244e288b1760", + "skills/gsd/gsd-ns-manage/skills/cleanup/SKILL.md": "f17b38ef046f6479", + "skills/gsd/gsd-ns-manage/skills/config/SKILL.md": "472d62f10987917e", + "skills/gsd/gsd-ns-manage/skills/health/SKILL.md": "b3718922fb10baf2", + "skills/gsd/gsd-ns-manage/skills/help/SKILL.md": "3bdd8a49c2d9eeb9", + "skills/gsd/gsd-ns-manage/skills/inbox/SKILL.md": "dad3f572dc97e8d9", + "skills/gsd/gsd-ns-manage/skills/manager/SKILL.md": "45a0123746b538da", + "skills/gsd/gsd-ns-manage/skills/pause-work/SKILL.md": "f6b91ab34a52ec67", + "skills/gsd/gsd-ns-manage/skills/pr-branch/SKILL.md": "7d7e51e684ed5df6", + "skills/gsd/gsd-ns-manage/skills/resume-work/SKILL.md": "d50c1aac9c702a8d", + "skills/gsd/gsd-ns-manage/skills/settings/SKILL.md": "16975a50842c06c6", + "skills/gsd/gsd-ns-manage/skills/ship/SKILL.md": "8a3a195e339c5ab6", + "skills/gsd/gsd-ns-manage/skills/stats/SKILL.md": "f77e8240d8754d5e", + "skills/gsd/gsd-ns-manage/skills/surface/SKILL.md": "b292d9414a3ceb7a", + "skills/gsd/gsd-ns-manage/skills/thread/SKILL.md": "3a9ca4ac926b85d9", + "skills/gsd/gsd-ns-manage/skills/undo/SKILL.md": "3609c2bd80383cc7", + "skills/gsd/gsd-ns-manage/skills/update/SKILL.md": "38865b7c53ce3bb2", + "skills/gsd/gsd-ns-manage/skills/workspace/SKILL.md": "0af8b7bb3cff8ec3", + "skills/gsd/gsd-ns-manage/skills/workstreams/SKILL.md": "4f5f41bee17fddda", + "skills/gsd/gsd-ns-project/SKILL.md": "d7462813249bba6e", + "skills/gsd/gsd-ns-project/skills/audit-milestone/SKILL.md": "b6e35c162eade47e", + "skills/gsd/gsd-ns-project/skills/complete-milestone/SKILL.md": "60bd096bbbf1bcde", + "skills/gsd/gsd-ns-project/skills/import/SKILL.md": "a244b8a416de4151", + "skills/gsd/gsd-ns-project/skills/ingest-docs/SKILL.md": "744e20b470d1f38d", + "skills/gsd/gsd-ns-project/skills/milestone-summary/SKILL.md": "a4a3934f5dccfafa", + "skills/gsd/gsd-ns-project/skills/new-milestone/SKILL.md": "be1186bd49fa793f", + "skills/gsd/gsd-ns-project/skills/new-project/SKILL.md": "4b9851a476337bec", + "skills/gsd/gsd-ns-project/skills/profile-user/SKILL.md": "174895b891961f4a", + "skills/gsd/gsd-ns-project/skills/review-backlog/SKILL.md": "23b62ff57db3db7a", + "skills/gsd/gsd-ns-review/SKILL.md": "b8f3b659ce8069c6", + "skills/gsd/gsd-ns-review/skills/audit-fix/SKILL.md": "4ac64b41a68e0271", + "skills/gsd/gsd-ns-review/skills/audit-uat/SKILL.md": "6818606a6428f4fd", + "skills/gsd/gsd-ns-review/skills/code-review/SKILL.md": "9bbf2a634954e692", + "skills/gsd/gsd-ns-review/skills/debug/SKILL.md": "014bf3421ade2813", + "skills/gsd/gsd-ns-review/skills/eval-review/SKILL.md": "609ee9ace8cd424c", + "skills/gsd/gsd-ns-review/skills/forensics/SKILL.md": "d31dc39e02abc929", + "skills/gsd/gsd-ns-review/skills/review/SKILL.md": "0629aeccf352b059", + "skills/gsd/gsd-ns-review/skills/secure-phase/SKILL.md": "f615390f01694378", + "skills/gsd/gsd-ns-review/skills/ui-phase/SKILL.md": "aac4a77d61281591", + "skills/gsd/gsd-ns-review/skills/ui-review/SKILL.md": "ebe37a6d521dba62", + "skills/gsd/gsd-ns-review/skills/validate-phase/SKILL.md": "b9f0c4f3738fcb7e", + "skills/gsd/gsd-ns-workflow/SKILL.md": "8ae955e65342e183", + "skills/gsd/gsd-ns-workflow/skills/add-tests/SKILL.md": "274b0e83a06204d5", + "skills/gsd/gsd-ns-workflow/skills/ai-integration-phase/SKILL.md": "8415c05314ef1091", + "skills/gsd/gsd-ns-workflow/skills/autonomous/SKILL.md": "67a1d1880678e0d5", + "skills/gsd/gsd-ns-workflow/skills/discuss-phase/SKILL.md": "3b69cd5de487382e", + "skills/gsd/gsd-ns-workflow/skills/execute-phase/SKILL.md": "2b838b5e5737aade", + "skills/gsd/gsd-ns-workflow/skills/fast/SKILL.md": "c22843abbd530ebd", + "skills/gsd/gsd-ns-workflow/skills/mvp-phase/SKILL.md": "615bfe82af659694", + "skills/gsd/gsd-ns-workflow/skills/phase/SKILL.md": "a407ca50ee0aeb6b", + "skills/gsd/gsd-ns-workflow/skills/plan-phase/SKILL.md": "7f077194059ede03", + "skills/gsd/gsd-ns-workflow/skills/plan-review-convergence/SKILL.md": "99468f6eaf3c72b4", + "skills/gsd/gsd-ns-workflow/skills/progress/SKILL.md": "6ecd0f958ad93d20", + "skills/gsd/gsd-ns-workflow/skills/quick/SKILL.md": "3ba1337427843e91", + "skills/gsd/gsd-ns-workflow/skills/spec-phase/SKILL.md": "83186cb129fdae50", + "skills/gsd/gsd-ns-workflow/skills/ultraplan-phase/SKILL.md": "bc4d84237531c5e5", + "skills/gsd/gsd-ns-workflow/skills/verify-work/SKILL.md": "aa830ff978d73f1a" +} diff --git a/tests/fixtures/golden-install-parity/kilo.json b/tests/fixtures/golden-install-parity/kilo.json new file mode 100644 index 000000000..3982d8f71 --- /dev/null +++ b/tests/fixtures/golden-install-parity/kilo.json @@ -0,0 +1,473 @@ +{ + ".gsd-profile": "0e716a5fef4e6dc1", + ".gsd/defaults.json": "560664b045e645cb", + "agents/gsd-advisor-researcher.md": "3f23cbf011be54b4", + "agents/gsd-ai-researcher.md": "16d5148566737df0", + "agents/gsd-assumptions-analyzer.md": "e638c7dbe0dd8405", + "agents/gsd-code-fixer.md": "dfe11ff351e10d8a", + "agents/gsd-code-reviewer.md": "501bb541628df0b9", + "agents/gsd-codebase-mapper.md": "1b5538846f0c743d", + "agents/gsd-debug-session-manager.md": "5fd509a86b1335b7", + "agents/gsd-debugger.md": "52c4715abce6ddb3", + "agents/gsd-doc-classifier.md": "dd7670f4fb345994", + "agents/gsd-doc-synthesizer.md": "1e987faef95a8c71", + "agents/gsd-doc-verifier.md": "8016f792d0766b77", + "agents/gsd-doc-writer.md": "5b54aa02048cb1a0", + "agents/gsd-domain-researcher.md": "a3874d80bcbc7380", + "agents/gsd-eval-auditor.md": "c3295817a354d799", + "agents/gsd-eval-planner.md": "3db12cde12aeb2c1", + "agents/gsd-executor.md": "053c4c953130bee2", + "agents/gsd-framework-selector.md": "ad5f2c6b9bec6270", + "agents/gsd-integration-checker.md": "a350dd0503ff992d", + "agents/gsd-intel-updater.md": "b8eb72873ded3c14", + "agents/gsd-mempalace-curator.md": "6461da48ed937556", + "agents/gsd-nyquist-auditor.md": "2cdb16a2b095b5e5", + "agents/gsd-pattern-mapper.md": "6a5408fd11d70391", + "agents/gsd-phase-researcher.md": "46374922b18d5b94", + "agents/gsd-plan-checker.md": "c61432fb8d9b72f6", + "agents/gsd-planner.md": "5b0cb992023412a2", + "agents/gsd-project-researcher.md": "b433211bb78ebb23", + "agents/gsd-research-synthesizer.md": "941b85961b58758d", + "agents/gsd-roadmapper.md": "5304db525ad7a243", + "agents/gsd-security-auditor.md": "2f39af0364f31cf5", + "agents/gsd-ui-auditor.md": "cee3fb0c241faaa3", + "agents/gsd-ui-checker.md": "2a6c5551e354414b", + "agents/gsd-ui-researcher.md": "384af56f90784422", + "agents/gsd-user-profiler.md": "b8cb09319c517701", + "agents/gsd-verifier.md": "b16febf0774e2db7", + "command/gsd-add-tests.md": "c314f9a6be312bfa", + "command/gsd-ai-integration-phase.md": "bbab86a540e00db5", + "command/gsd-audit-fix.md": "4106e9dce9b71585", + "command/gsd-audit-milestone.md": "0f627d956b5030ed", + "command/gsd-audit-uat.md": "119f204ca3cd0cd6", + "command/gsd-autonomous.md": "b85f720c4675d567", + "command/gsd-capture.md": "678c0ff889b2ad77", + "command/gsd-cleanup.md": "36cfddb3528b7c46", + "command/gsd-code-review.md": "a60c5b90d7d40c43", + "command/gsd-complete-milestone.md": "c7f5d90ab35bfff6", + "command/gsd-config.md": "3ec39530e1569ea7", + "command/gsd-debug.md": "aa1b885755bf3469", + "command/gsd-discuss-phase.md": "718155162fa3557a", + "command/gsd-docs-update.md": "d9d1d24eba748102", + "command/gsd-eval-review.md": "6f0737654a3fd4bb", + "command/gsd-execute-phase.md": "ce4cdfb79eaf6403", + "command/gsd-explore.md": "c161fa4bf2caceef", + "command/gsd-extract-learnings.md": "d18cbd1a3a03f845", + "command/gsd-fast.md": "66cc15c968a72270", + "command/gsd-forensics.md": "219d4a8d7241603a", + "command/gsd-graphify.md": "043152b6c9d82538", + "command/gsd-health.md": "4e45942f1cc53493", + "command/gsd-help.md": "4ae69106952f5d23", + "command/gsd-import.md": "8038513633f0470b", + "command/gsd-inbox.md": "1429fc7f556337c3", + "command/gsd-ingest-docs.md": "e655ecf1d4433ba9", + "command/gsd-manager.md": "1cdc133079833552", + "command/gsd-map-codebase.md": "186c8f79319abc1c", + "command/gsd-mempalace-capture.md": "08b9750f1b1d1ba6", + "command/gsd-mempalace-recall.md": "2316f1a950490845", + "command/gsd-milestone-summary.md": "4ab29b777f62f0bb", + "command/gsd-mvp-phase.md": "273a1c26ca13274b", + "command/gsd-new-milestone.md": "a315b3205c5d3470", + "command/gsd-new-project.md": "f2763e2ccbb70ebe", + "command/gsd-ns-context.md": "9a2b55b64f2e1e4b", + "command/gsd-ns-ideate.md": "27e7fcdc69eeeb5f", + "command/gsd-ns-manage.md": "eded59052ca4b240", + "command/gsd-ns-project.md": "91417cf7b76581dd", + "command/gsd-ns-review.md": "61fe33e28eb1b961", + "command/gsd-ns-workflow.md": "3352c292c27fe50b", + "command/gsd-pause-work.md": "04e993b1c9f8322b", + "command/gsd-phase.md": "8f0e98dc6c223229", + "command/gsd-plan-phase.md": "b37e9a61f946c975", + "command/gsd-plan-review-convergence.md": "79a28ef110f9f481", + "command/gsd-pr-branch.md": "68e724607de3c480", + "command/gsd-profile-user.md": "9704158b2d79cad2", + "command/gsd-progress.md": "cba4805469307416", + "command/gsd-quick.md": "704662b2172df14d", + "command/gsd-resume-work.md": "e4bb44fb2e8076c0", + "command/gsd-review-backlog.md": "1e99d6a7a3806fa5", + "command/gsd-review.md": "5f6efc83706fec9b", + "command/gsd-secure-phase.md": "65eb1cd3fa6430a5", + "command/gsd-settings.md": "9bf5d13f13f7d49c", + "command/gsd-ship.md": "c0ae46a2cbc7a2b4", + "command/gsd-sketch.md": "5237dfdbb55735db", + "command/gsd-spec-phase.md": "8f8e3e109ebb7011", + "command/gsd-spike.md": "fdcb1ae289790818", + "command/gsd-stats.md": "b54c0d533cff9710", + "command/gsd-surface.md": "3290127fe8241ced", + "command/gsd-thread.md": "9c6a1900b3c57be6", + "command/gsd-ui-phase.md": "efeba2d2381173e3", + "command/gsd-ui-review.md": "35a12e93cddf266c", + "command/gsd-ultraplan-phase.md": "f2cfeb972bbb90f7", + "command/gsd-undo.md": "2522a6a2fbc1d0d2", + "command/gsd-update.md": "6f1d4ce787925990", + "command/gsd-validate-phase.md": "f647bae9dba988bb", + "command/gsd-verify-work.md": "d07409c940a6ff00", + "command/gsd-workspace.md": "9048133312f47fdc", + "command/gsd-workstreams.md": "5e57eed1881c3891", + "gsd-core/CHANGELOG.md": "e141e3fb369ff712", + "gsd-core/VERSION": "562368b20a64be95", + "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", + "gsd-core/bin/gsd-tools.cjs": "b7968e3e3af00249", + "gsd-core/bin/gsd_run": "62d9b647ede212e6", + "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", + "gsd-core/bin/shared/config-schema.manifest.json": "67e4addbfd248a7c", + "gsd-core/bin/shared/model-catalog.json": "dbe26e683236d8c6", + "gsd-core/bin/shared/runtime-aliases.manifest.json": "f6c8b3af10dcfdc4", + "gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904", + "gsd-core/contexts/dev.md": "dcb0de9dce33cf41", + "gsd-core/contexts/research.md": "b3285d8e7209cc3b", + "gsd-core/contexts/review.md": "dc578fdd74bbea11", + "gsd-core/references/agent-contracts.md": "ff65e633c656c0d2", + "gsd-core/references/ai-evals.md": "b5afa786b938671e", + "gsd-core/references/ai-frameworks.md": "425dd69c629230e7", + "gsd-core/references/artifact-types.md": "8bd01fd75a2ba70e", + "gsd-core/references/autonomous-smart-discuss.md": "3986d58011bf9006", + "gsd-core/references/checkpoints.md": "9feb961f644afa96", + "gsd-core/references/common-bug-patterns.md": "780145be56352626", + "gsd-core/references/context-budget.md": "533eae480bfc4bb8", + "gsd-core/references/continuation-format.md": "580287399ad3ba68", + "gsd-core/references/debugger-philosophy.md": "1cb61d4565081c88", + "gsd-core/references/decimal-phase-calculation.md": "46b5ba045852c474", + "gsd-core/references/doc-conflict-engine.md": "883d0a1b9d9ff96e", + "gsd-core/references/domain-probes.md": "762b965e84035b72", + "gsd-core/references/edge-probe-fixtures/01-round-half-even/expected-coverage.json": "72d1e29cedc854ec", + "gsd-core/references/edge-probe-fixtures/01-round-half-even/requirements.json": "fbc1b355d8625eeb", + "gsd-core/references/edge-probe-fixtures/02-merge-intervals/expected-coverage.json": "fad67dcc8294f6da", + "gsd-core/references/edge-probe-fixtures/02-merge-intervals/requirements.json": "30a78ee9ce3473ea", + "gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/expected-coverage.json": "66dd60957fee45f0", + "gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/requirements.json": "47fca61f076835fa", + "gsd-core/references/edge-probe-fixtures/04-money-rounding/expected-coverage.json": "72d1e29cedc854ec", + "gsd-core/references/edge-probe-fixtures/04-money-rounding/requirements.json": "80f04f5c04fb24cf", + "gsd-core/references/edge-probe-fixtures/05-list-dedupe/expected-coverage.json": "fad67dcc8294f6da", + "gsd-core/references/edge-probe-fixtures/05-list-dedupe/requirements.json": "d38147adb0e5b342", + "gsd-core/references/edge-probe-fixtures/06-resolved-mixed/expected-coverage.json": "bc552c01939bf4f8", + "gsd-core/references/edge-probe-fixtures/06-resolved-mixed/requirements.json": "30a78ee9ce3473ea", + "gsd-core/references/edge-probe-fixtures/06-resolved-mixed/resolutions.json": "688ec62c13e08afe", + "gsd-core/references/edge-probe.md": "5687eba25a078561", + "gsd-core/references/execute-mvp-tdd.md": "a98a270a7ab126bc", + "gsd-core/references/execute-phase-between-wave-reset.md": "3ad96ca0f7fee37e", + "gsd-core/references/execute-phase-context-guard.md": "a5a1058d35806a8e", + "gsd-core/references/execute-phase-wave-guard.md": "de9ac22cead4cfd8", + "gsd-core/references/executor-examples.md": "ba59243ed45c8ab1", + "gsd-core/references/few-shot-examples/plan-checker.md": "2574808188ac9de4", + "gsd-core/references/few-shot-examples/verifier.md": "5badee4560b14ae8", + "gsd-core/references/gate-prompts.md": "43e867720e736464", + "gsd-core/references/gates.md": "7dc9fd3a3d6217c6", + "gsd-core/references/git-integration.md": "fbdf814a3af9c051", + "gsd-core/references/git-planning-commit.md": "f897a15ebfc3f5a7", + "gsd-core/references/ios-scaffold.md": "5ef0cb7e0fac891f", + "gsd-core/references/loop-hook-dispatch.md": "32e5dfb4dba76987", + "gsd-core/references/mandatory-initial-read.md": "fe59abce693717cf", + "gsd-core/references/model-profile-resolution.md": "f32bb05102839767", + "gsd-core/references/model-profiles.md": "9e160d4e754b6b9e", + "gsd-core/references/mvp-concepts.md": "3464783eaaef5c10", + "gsd-core/references/phase-argument-parsing.md": "e5bbb985f3bc3e34", + "gsd-core/references/planner-antipatterns.md": "2459c40bfc8a0ea8", + "gsd-core/references/planner-chunked.md": "79fe674221e738e6", + "gsd-core/references/planner-gap-closure.md": "76bee257911413e7", + "gsd-core/references/planner-graphify-auto-update.md": "1ed614dfba72f2a3", + "gsd-core/references/planner-guidance.md": "b791ceb40cb79d8c", + "gsd-core/references/planner-human-verify-mode.md": "3a625b42d9cb93ee", + "gsd-core/references/planner-interface-context.md": "b28fa3da6ae739a8", + "gsd-core/references/planner-load-graph-context.md": "ca7a7af3f35ae61b", + "gsd-core/references/planner-mvp-mode.md": "ec33050db81101a8", + "gsd-core/references/planner-reviews.md": "da39eace09a10743", + "gsd-core/references/planner-revision.md": "86ba8a511f081f05", + "gsd-core/references/planner-source-audit.md": "7de5bdb07232ce0b", + "gsd-core/references/planning-config.md": "4acefb3c71169c84", + "gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json": "f10df472f2846cc6", + "gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json": "31e8a781eeffe020", + "gsd-core/references/prohibition-probe-fixtures/03-multi-prohibition/expected.json": "70a532a7cc1b6ae8", + "gsd-core/references/prohibition-probe.md": "52872d5393ea9daf", + "gsd-core/references/project-skills-discovery.md": "0ada2e0c44531865", + "gsd-core/references/questioning.md": "a083e5e3e16dd802", + "gsd-core/references/research-documentation-lookup.md": "c070007d1d72ab71", + "gsd-core/references/research-philosophy.md": "a602b41bfa081fdf", + "gsd-core/references/research-verification-protocol.md": "9c38c9d9a687e679", + "gsd-core/references/revision-loop.md": "e55ff32dd98c63df", + "gsd-core/references/scout-codebase.md": "ba266ecc18fbf172", + "gsd-core/references/security-asvs-levels.md": "4774fac3b94b6ca8", + "gsd-core/references/skeleton-template.md": "528691d1f0efa878", + "gsd-core/references/sketch-interactivity.md": "7d982fe877e1e1cc", + "gsd-core/references/sketch-theme-system.md": "33e2e96e450456f8", + "gsd-core/references/sketch-tooling.md": "df6c4f24c1c27611", + "gsd-core/references/sketch-variant-patterns.md": "66c197aa4fb52810", + "gsd-core/references/spidr-splitting.md": "074ac154c0e4f906", + "gsd-core/references/tdd.md": "e4708ede157478b6", + "gsd-core/references/thinking-models-debug.md": "2da61022b16c4e7c", + "gsd-core/references/thinking-models-execution.md": "dcc650a8b5f3e049", + "gsd-core/references/thinking-models-planning.md": "7e19462313fa028f", + "gsd-core/references/thinking-models-research.md": "5f6bf3f3b889c6e4", + "gsd-core/references/thinking-models-verification.md": "a71a933d51ca3d8d", + "gsd-core/references/thinking-partner.md": "827c1badf3e6df41", + "gsd-core/references/ui-brand.md": "48717bcfcd63bd27", + "gsd-core/references/universal-anti-patterns.md": "6a1245050b21df01", + "gsd-core/references/untrusted-input-boundary.md": "d33b80d4d348599a", + "gsd-core/references/user-profiling.md": "8e023a908d968af1", + "gsd-core/references/user-story-template.md": "0cc50e06a144ff8a", + "gsd-core/references/verification-overrides.md": "a3e2d5166d16a37b", + "gsd-core/references/verification-patterns.md": "ac45d99076067f17", + "gsd-core/references/verify-mvp-mode.md": "534bdc7f2432903a", + "gsd-core/references/workstream-flag.md": "225910640adbb76d", + "gsd-core/references/worktree-branch-check.md": "21d9c31bf6542b93", + "gsd-core/references/worktree-path-safety.md": "3c8d74756f9b16a8", + "gsd-core/templates/AI-SPEC.md": "24df5fe5ba34e367", + "gsd-core/templates/DEBUG.md": "a2eea97e7f1336bf", + "gsd-core/templates/README.md": "73d3c9689b6efbc9", + "gsd-core/templates/SECURITY.md": "b628f7f1c6d2328f", + "gsd-core/templates/UAT.md": "68d32d1fea14e184", + "gsd-core/templates/UI-SPEC.md": "20ca56a4e3e21f01", + "gsd-core/templates/VALIDATION.md": "6144951011cdca57", + "gsd-core/templates/claude-md.md": "f35856254768bf7d", + "gsd-core/templates/codebase/architecture.md": "282db635ba093b1a", + "gsd-core/templates/codebase/concerns.md": "e66c584daa636fc5", + "gsd-core/templates/codebase/conventions.md": "c5f0324730852701", + "gsd-core/templates/codebase/integrations.md": "39bd23c71eedd564", + "gsd-core/templates/codebase/stack.md": "116e7e67dd87ddec", + "gsd-core/templates/codebase/structure.md": "5b5dd37145241462", + "gsd-core/templates/codebase/testing.md": "76abff7f2050c9ea", + "gsd-core/templates/config.json": "1473dff11272d3c4", + "gsd-core/templates/context.md": "593d4e6d20fdde7c", + "gsd-core/templates/continue-here.md": "8b1dce4832c7922b", + "gsd-core/templates/copilot-instructions.md": "aea34bc52ff548ea", + "gsd-core/templates/debug-subagent-prompt.md": "8c18a89e25929d8e", + "gsd-core/templates/dev-preferences.md": "95048a71063d980b", + "gsd-core/templates/discovery.md": "e4ab738326eb70e0", + "gsd-core/templates/discussion-log.md": "da4e8e266efdc6c2", + "gsd-core/templates/milestone-archive.md": "591b6decdc0c0e51", + "gsd-core/templates/milestone.md": "74d2f750ae9f4a9c", + "gsd-core/templates/phase-prompt.md": "2e6fd96c2cc26470", + "gsd-core/templates/planner-subagent-prompt.md": "6c9f1b23ee3dc05f", + "gsd-core/templates/project.md": "e4a3b331be081e62", + "gsd-core/templates/requirements.md": "a44de4c2f146e473", + "gsd-core/templates/research-project/ARCHITECTURE.md": "746b9ef791d758b0", + "gsd-core/templates/research-project/FEATURES.md": "f2b800de5df91b0f", + "gsd-core/templates/research-project/PITFALLS.md": "3ef75fa768422eec", + "gsd-core/templates/research-project/STACK.md": "82c85799ac4dd344", + "gsd-core/templates/research-project/SUMMARY.md": "dceb2f346388839d", + "gsd-core/templates/research.md": "b454f96fdf8b1d3d", + "gsd-core/templates/retrospective.md": "03981e30dd760103", + "gsd-core/templates/roadmap.md": "e4e35a9eb5dd4d4f", + "gsd-core/templates/spec.md": "8734f0df4df3a34b", + "gsd-core/templates/state.md": "a45a134631efe3f9", + "gsd-core/templates/summary-complex.md": "a5e40574fd8894dc", + "gsd-core/templates/summary-minimal.md": "7d09b5e709e2e67c", + "gsd-core/templates/summary-standard.md": "e8d9cf4a8377cdff", + "gsd-core/templates/summary.md": "23c40f6503b3ea98", + "gsd-core/templates/user-profile.md": "52abe2af968e8533", + "gsd-core/templates/user-setup.md": "1da2382725db080f", + "gsd-core/templates/verification-report.md": "78ab9264ce63ed7e", + "gsd-core/workflows/_runtime-launcher.snippet.sh": "c0994e00f976dbfb", + "gsd-core/workflows/add-backlog.md": "5095b6e69b4e380a", + "gsd-core/workflows/add-phase.md": "27164a671e14a789", + "gsd-core/workflows/add-tests.md": "5bb66a4f4c6839bb", + "gsd-core/workflows/add-todo.md": "1a3b827cda68adde", + "gsd-core/workflows/ai-integration-phase.md": "87b004f0f20fe211", + "gsd-core/workflows/analyze-dependencies.md": "77aff48f97fa6f1c", + "gsd-core/workflows/audit-fix.md": "eaead7ea85761e5b", + "gsd-core/workflows/audit-milestone.md": "5e9a35af57344e04", + "gsd-core/workflows/audit-uat.md": "69dbdec25cae2f12", + "gsd-core/workflows/autonomous.md": "ac4f18090bd4b178", + "gsd-core/workflows/check-todos.md": "cde0a3025828bad8", + "gsd-core/workflows/cleanup.md": "d124682912dab9f6", + "gsd-core/workflows/code-review-fix.md": "a416c764425cbb61", + "gsd-core/workflows/code-review.md": "3999e5bc9ce9171e", + "gsd-core/workflows/complete-milestone.md": "cb9a1ad0d611c765", + "gsd-core/workflows/debug.md": "0fa12d266c29d9d4", + "gsd-core/workflows/diagnose-issues.md": "af98c318e0aa27bf", + "gsd-core/workflows/discovery-phase.md": "ca7b2be46e59e862", + "gsd-core/workflows/discuss-phase-assumptions.md": "6419d394b232d7b0", + "gsd-core/workflows/discuss-phase-power.md": "5c0d1ca6abda0383", + "gsd-core/workflows/discuss-phase.md": "322f0c5d18b87e5a", + "gsd-core/workflows/discuss-phase/modes/advisor.md": "0b16982b70abfcc2", + "gsd-core/workflows/discuss-phase/modes/all.md": "5ed71228ac96e728", + "gsd-core/workflows/discuss-phase/modes/analyze.md": "da0788f3be7f8105", + "gsd-core/workflows/discuss-phase/modes/auto.md": "28dc6398e331b418", + "gsd-core/workflows/discuss-phase/modes/batch.md": "78f6ef61690acb66", + "gsd-core/workflows/discuss-phase/modes/chain.md": "b16547a6b0423579", + "gsd-core/workflows/discuss-phase/modes/default.md": "5d12f2593ae4a813", + "gsd-core/workflows/discuss-phase/modes/power.md": "fdc7a728eaaa2261", + "gsd-core/workflows/discuss-phase/modes/text.md": "daf23a04b101ab38", + "gsd-core/workflows/discuss-phase/templates/checkpoint.json": "e3bc3dca49db59eb", + "gsd-core/workflows/discuss-phase/templates/context.md": "9804674fccada405", + "gsd-core/workflows/discuss-phase/templates/discussion-log.md": "04e4feee14772275", + "gsd-core/workflows/do.md": "c1aeb36469f7badc", + "gsd-core/workflows/docs-update.md": "87bf2a6b7b6ec9db", + "gsd-core/workflows/edit-phase.md": "a3ab51739c5c021c", + "gsd-core/workflows/eval-review.md": "f6183650f7fcabf9", + "gsd-core/workflows/execute-phase.md": "ff4e037f1a2afa58", + "gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md": "c133828cf177a772", + "gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md": "7ebb7d1af6082028", + "gsd-core/workflows/execute-phase/steps/post-merge-gate.md": "abd2aca069c04a80", + "gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md": "be84efbd71e1513e", + "gsd-core/workflows/execute-plan.md": "9b96f0ef3d149b2c", + "gsd-core/workflows/explore.md": "a1aa05e598caf7e3", + "gsd-core/workflows/extract-learnings.md": "ce4b5388074f19a3", + "gsd-core/workflows/fast.md": "54fe93778b45a7eb", + "gsd-core/workflows/forensics.md": "d0f079d1bcf924c3", + "gsd-core/workflows/graduation.md": "fc01dc810d4845f8", + "gsd-core/workflows/health.md": "955d5ebacd5740e6", + "gsd-core/workflows/help.md": "5d040504b9ab35e3", + "gsd-core/workflows/help/modes/brief.md": "2e923b3ed635d495", + "gsd-core/workflows/help/modes/default.md": "b544113eb9aa54fa", + "gsd-core/workflows/help/modes/full.md": "737a6396e998929e", + "gsd-core/workflows/help/modes/topic.md": "a404990fcf2d7df7", + "gsd-core/workflows/import.md": "95734bf6d48ef776", + "gsd-core/workflows/inbox.md": "a073396097c89c01", + "gsd-core/workflows/ingest-docs.md": "270a4dc138d239c4", + "gsd-core/workflows/insert-phase.md": "0ab06e370253622b", + "gsd-core/workflows/list-phase-assumptions.md": "e7b5e35c9452e3b3", + "gsd-core/workflows/list-seeds.md": "da9511b569ada7f9", + "gsd-core/workflows/list-workspaces.md": "9b78bb9f71029169", + "gsd-core/workflows/manager.md": "bedfe8a2a48a9605", + "gsd-core/workflows/map-codebase.md": "fcc2796ebf8ea2c7", + "gsd-core/workflows/milestone-summary.md": "423287cd509b7774", + "gsd-core/workflows/mvp-phase.md": "3935c0d9969e3e84", + "gsd-core/workflows/new-milestone.md": "c4ff690cd48a52db", + "gsd-core/workflows/new-project.md": "0dce9298c090bdb4", + "gsd-core/workflows/new-workspace.md": "074efbc589061182", + "gsd-core/workflows/next.md": "0a9b4e23ac0bf7ea", + "gsd-core/workflows/node-repair.md": "07a1628e5a1ff96b", + "gsd-core/workflows/note.md": "f8c2842a2217f776", + "gsd-core/workflows/pause-work.md": "8b81699a46ca8e9b", + "gsd-core/workflows/plan-milestone-gaps.md": "1976bf2001969719", + "gsd-core/workflows/plan-phase.md": "2d8eda8f283735d2", + "gsd-core/workflows/plan-review-convergence.md": "88e294a5cc616e90", + "gsd-core/workflows/plant-seed.md": "249d3c6b4d474106", + "gsd-core/workflows/pr-branch.md": "c8827e8a15426bf5", + "gsd-core/workflows/profile-user.md": "9d0f46424df26c8f", + "gsd-core/workflows/progress.md": "e7522beba5bd09b6", + "gsd-core/workflows/quick.md": "a1f1c8384cc51667", + "gsd-core/workflows/reapply-patches.md": "2c44426624047ed0", + "gsd-core/workflows/remove-phase.md": "f76e1c2a4dd31a09", + "gsd-core/workflows/remove-workspace.md": "7bd5d1b63ef091a8", + "gsd-core/workflows/resume-project.md": "3dcaa7abe1800d35", + "gsd-core/workflows/review.md": "268745662d5f5df7", + "gsd-core/workflows/scan.md": "8e1bbf2eed1752ca", + "gsd-core/workflows/secure-phase.md": "87fdcb37a8610e58", + "gsd-core/workflows/session-report.md": "2e5b1205324ddefa", + "gsd-core/workflows/settings-advanced.md": "910b34606d32df5b", + "gsd-core/workflows/settings-integrations.md": "4d23553b9143e834", + "gsd-core/workflows/settings.md": "c345ec6b9b5ee963", + "gsd-core/workflows/ship.md": "b6149b0bc45cc759", + "gsd-core/workflows/sketch-wrap-up.md": "7673797d65af2377", + "gsd-core/workflows/sketch.md": "201772f9c5c02e97", + "gsd-core/workflows/spec-phase.md": "2c67506aeb25a7e1", + "gsd-core/workflows/spike-wrap-up.md": "b7ee3d961b5792c6", + "gsd-core/workflows/spike.md": "a782dd863771fe0a", + "gsd-core/workflows/stats.md": "17b4f2059f4b4ef2", + "gsd-core/workflows/sync-skills.md": "dc7b8b015afa4b3b", + "gsd-core/workflows/thread.md": "75df5cc71f72c33d", + "gsd-core/workflows/transition.md": "9040a76741f12de6", + "gsd-core/workflows/ui-phase.md": "33f7113e91c2bfe0", + "gsd-core/workflows/ui-review.md": "013272816a291305", + "gsd-core/workflows/ultraplan-phase.md": "db7df53187dae993", + "gsd-core/workflows/undo.md": "7cd2153f8b15e30d", + "gsd-core/workflows/update.md": "5d0a2356dadf2017", + "gsd-core/workflows/validate-phase.md": "7915e3a261f7c9c9", + "gsd-core/workflows/verify-phase.md": "452968b6becb18a1", + "gsd-core/workflows/verify-work.md": "7cb5144cc74986e7", + "hooks/gsd-check-update-worker.js": "8c48db40d2d74193", + "hooks/gsd-check-update.js": "1c863b30953b47c8", + "hooks/gsd-config-reload.js": "17bf778d432b3d2a", + "hooks/gsd-context-monitor.js": "8e9c39563be10827", + "hooks/gsd-cursor-post-tool.js": "d61ee04f6ee7858c", + "hooks/gsd-cursor-session-start.js": "148b8ec4e2c97f00", + "hooks/gsd-ensure-canonical-path.js": "a5c67a1a7abc90c0", + "hooks/gsd-graphify-update.sh": "396ebda3c6705dc9", + "hooks/gsd-phase-boundary.sh": "6aa3ba9af3d465d9", + "hooks/gsd-prompt-guard.js": "4b08c2dce0233e2d", + "hooks/gsd-read-guard.js": "b602f88f046a7551", + "hooks/gsd-read-injection-scanner.js": "c5d388fe1a61a12a", + "hooks/gsd-session-state.sh": "b1496e6a5204a6df", + "hooks/gsd-statusline.js": "e6eeb0972eb54bbe", + "hooks/gsd-update-banner.js": "74817c820b7a4ec1", + "hooks/gsd-validate-commit.sh": "14d3d966c74dc310", + "hooks/gsd-workflow-guard.js": "e22b9fb57f0e64f6", + "hooks/gsd-worktree-path-guard.js": "ca0d1af4a9357887", + "hooks/lib/git-cmd.js": "268ba15992ca0b23", + "hooks/lib/gsd-graphify-rebuild.sh": "66af89601074d2a9", + "hooks/managed-hooks-registry.cjs": "8a9f7633c6fe0ea0", + "kilo.json": "13151e97ff23c1aa", + "package.json": "dbf8353f77358bc1", + "scripts/changeset/README.md": "86ff89331dfd94b2", + "scripts/changeset/cli.cjs": "68f92a344b199271", + "scripts/changeset/github-release-notes.cjs": "795677f0c009b132", + "scripts/changeset/lint.cjs": "0066faed159154f0", + "scripts/changeset/new.cjs": "4991e21fd17f5541", + "scripts/changeset/parse.cjs": "f9a949cbcab56445", + "scripts/changeset/render.cjs": "e47bc3e1587c3cae", + "scripts/changeset/serialize.cjs": "ac0b8fe6f87cdb0e", + "scripts/fix-slash-commands.cjs": "0519742531ff3529", + "scripts/lib/allowlist-ratchet.cjs": "ffaceaac3efc2660", + "scripts/lib/cli-exit.cjs": "612d0c372c75b7e7", + "skills/gsd-add-tests/SKILL.md": "addc3a220961a9c7", + "skills/gsd-ai-integration-phase/SKILL.md": "e74b83a991dc9fc7", + "skills/gsd-audit-fix/SKILL.md": "2c1f601f2fb52585", + "skills/gsd-audit-milestone/SKILL.md": "739ed755ad84e58e", + "skills/gsd-audit-uat/SKILL.md": "2c450432b6fd1c3e", + "skills/gsd-autonomous/SKILL.md": "37dc7b78ceb74803", + "skills/gsd-capture/SKILL.md": "6536a7dcc4ef6c46", + "skills/gsd-cleanup/SKILL.md": "a578d6536b9cabc3", + "skills/gsd-code-review/SKILL.md": "c0db8e37e7133bd8", + "skills/gsd-complete-milestone/SKILL.md": "048fd8cf3f502433", + "skills/gsd-config/SKILL.md": "5d6f52a964d06e4d", + "skills/gsd-debug/SKILL.md": "f5ca8a5780d89659", + "skills/gsd-discuss-phase/SKILL.md": "d9465728b0582991", + "skills/gsd-docs-update/SKILL.md": "d150afd41a25ec08", + "skills/gsd-eval-review/SKILL.md": "c76022e94ee30cb1", + "skills/gsd-execute-phase/SKILL.md": "e0a4298151e692b3", + "skills/gsd-explore/SKILL.md": "d8e26a5ed95a4ecc", + "skills/gsd-extract-learnings/SKILL.md": "d39269bea995fabc", + "skills/gsd-fast/SKILL.md": "8773badbed73f5b1", + "skills/gsd-forensics/SKILL.md": "68952d0d846737ff", + "skills/gsd-graphify/SKILL.md": "dfe8264197d8e525", + "skills/gsd-health/SKILL.md": "74a68e1c1b310bef", + "skills/gsd-help/SKILL.md": "8d01c91265b6357f", + "skills/gsd-import/SKILL.md": "f76076ae9fbc8b1d", + "skills/gsd-inbox/SKILL.md": "34370c6138415209", + "skills/gsd-ingest-docs/SKILL.md": "f049f06a6ec7da43", + "skills/gsd-manager/SKILL.md": "a37ecdbe32952262", + "skills/gsd-map-codebase/SKILL.md": "e8c01704e8ada1d2", + "skills/gsd-mempalace-capture/SKILL.md": "260e1dabc7014ba2", + "skills/gsd-mempalace-recall/SKILL.md": "045d85c8cab1afcc", + "skills/gsd-milestone-summary/SKILL.md": "da645171ca2299d8", + "skills/gsd-mvp-phase/SKILL.md": "eeb0ac0f88647475", + "skills/gsd-new-milestone/SKILL.md": "bc8989d97f3161ad", + "skills/gsd-new-project/SKILL.md": "c08047e101dbac9c", + "skills/gsd-ns-context/SKILL.md": "110fb2227bcde535", + "skills/gsd-ns-ideate/SKILL.md": "d093d59a8519decc", + "skills/gsd-ns-manage/SKILL.md": "2756981de613022e", + "skills/gsd-ns-project/SKILL.md": "757976d48e8b7a26", + "skills/gsd-ns-review/SKILL.md": "680bdd33c882b049", + "skills/gsd-ns-workflow/SKILL.md": "68effe2fc2c65317", + "skills/gsd-pause-work/SKILL.md": "34366b18a392a717", + "skills/gsd-phase/SKILL.md": "64a241d4f8665aa2", + "skills/gsd-plan-phase/SKILL.md": "c73cba04a26f0bfe", + "skills/gsd-plan-review-convergence/SKILL.md": "fae05d6ab16cac10", + "skills/gsd-pr-branch/SKILL.md": "a80da6aa95efc50d", + "skills/gsd-profile-user/SKILL.md": "4ac2c5ea45d17a9d", + "skills/gsd-progress/SKILL.md": "493f467c22d55b6b", + "skills/gsd-quick/SKILL.md": "605e596c680cbb1c", + "skills/gsd-resume-work/SKILL.md": "0c92e4a51d1b6d94", + "skills/gsd-review-backlog/SKILL.md": "27a9dbfe92e5d04c", + "skills/gsd-review/SKILL.md": "7309817dc0d54cef", + "skills/gsd-secure-phase/SKILL.md": "ceded474164b4e2d", + "skills/gsd-settings/SKILL.md": "4587e4bef7b8942c", + "skills/gsd-ship/SKILL.md": "4ad9695934e069ee", + "skills/gsd-sketch/SKILL.md": "d062bef61ffe98a7", + "skills/gsd-spec-phase/SKILL.md": "749dee6f739b9b44", + "skills/gsd-spike/SKILL.md": "0938ac1386ca4a58", + "skills/gsd-stats/SKILL.md": "25898070fb2a4b20", + "skills/gsd-surface/SKILL.md": "1bfb5b380f42a02b", + "skills/gsd-thread/SKILL.md": "d5917840279459f1", + "skills/gsd-ui-phase/SKILL.md": "c35f175ccc747d56", + "skills/gsd-ui-review/SKILL.md": "48267dc071481a89", + "skills/gsd-ultraplan-phase/SKILL.md": "5de4d0d88ce11eb3", + "skills/gsd-undo/SKILL.md": "23c275bc4a127bde", + "skills/gsd-update/SKILL.md": "f2ed6c6d4d89c32c", + "skills/gsd-validate-phase/SKILL.md": "62dc6ae62bb38c16", + "skills/gsd-verify-work/SKILL.md": "9b185c0aa2e000a8", + "skills/gsd-workspace/SKILL.md": "f8eac0ef91888629", + "skills/gsd-workstreams/SKILL.md": "44c62598142837b9" +} diff --git a/tests/fixtures/golden-install-parity/kimi.json b/tests/fixtures/golden-install-parity/kimi.json new file mode 100644 index 000000000..7cb59948b --- /dev/null +++ b/tests/fixtures/golden-install-parity/kimi.json @@ -0,0 +1,417 @@ +{ + ".gsd-profile": "0e716a5fef4e6dc1", + ".gsd/defaults.json": "560664b045e645cb", + "agents/gsd.md": "60fee7782ae4f2c6", + "agents/gsd.yaml": "253a23ddda06c6c2", + "agents/subagents/gsd-advisor-researcher.md": "20bd45ab94df7931", + "agents/subagents/gsd-advisor-researcher.yaml": "662ced4837207406", + "agents/subagents/gsd-ai-researcher.md": "4a319934ab75a92a", + "agents/subagents/gsd-ai-researcher.yaml": "59edec2a11eb0a27", + "agents/subagents/gsd-assumptions-analyzer.md": "e88e552aae9d3b90", + "agents/subagents/gsd-assumptions-analyzer.yaml": "e7da772d8c7e3723", + "agents/subagents/gsd-code-fixer.md": "ee1ff10914839eff", + "agents/subagents/gsd-code-fixer.yaml": "41147d668c7e75cb", + "agents/subagents/gsd-code-reviewer.md": "702bb1240d755c20", + "agents/subagents/gsd-code-reviewer.yaml": "5f2398f56018f50d", + "agents/subagents/gsd-codebase-mapper.md": "39530e6f19156b36", + "agents/subagents/gsd-codebase-mapper.yaml": "bce1c6d15f55c477", + "agents/subagents/gsd-debug-session-manager.md": "54f87b28876dec1b", + "agents/subagents/gsd-debug-session-manager.yaml": "aab147717b5082e7", + "agents/subagents/gsd-debugger.md": "e786e8eb395bc494", + "agents/subagents/gsd-debugger.yaml": "6d02d7feb90cad43", + "agents/subagents/gsd-doc-classifier.md": "7810ff57b2dba86b", + "agents/subagents/gsd-doc-classifier.yaml": "a4e9cf7025b57503", + "agents/subagents/gsd-doc-synthesizer.md": "50cf72bc816460fd", + "agents/subagents/gsd-doc-synthesizer.yaml": "b4cfc60e11571632", + "agents/subagents/gsd-doc-verifier.md": "6b6eb3998d2167c8", + "agents/subagents/gsd-doc-verifier.yaml": "7200c09cefcd7667", + "agents/subagents/gsd-doc-writer.md": "067f34dc1387652f", + "agents/subagents/gsd-doc-writer.yaml": "166fa569d11adb9f", + "agents/subagents/gsd-domain-researcher.md": "c29b96a2c40f6e96", + "agents/subagents/gsd-domain-researcher.yaml": "269e21863e94f29f", + "agents/subagents/gsd-eval-auditor.md": "7c8e1106f1d53717", + "agents/subagents/gsd-eval-auditor.yaml": "e3d868bd5fefe938", + "agents/subagents/gsd-eval-planner.md": "70f8c5727bfb9876", + "agents/subagents/gsd-eval-planner.yaml": "df8499f7af297ec2", + "agents/subagents/gsd-executor.md": "362cf321f6d13907", + "agents/subagents/gsd-executor.yaml": "e29422986636fd64", + "agents/subagents/gsd-framework-selector.md": "a15b7aa1e0576e16", + "agents/subagents/gsd-framework-selector.yaml": "fb52c31cde27b0e3", + "agents/subagents/gsd-integration-checker.md": "6cd492199a92e0f8", + "agents/subagents/gsd-integration-checker.yaml": "3500c65ccbbe432d", + "agents/subagents/gsd-intel-updater.md": "8ffa8767b2270472", + "agents/subagents/gsd-intel-updater.yaml": "01445ee99bec4b31", + "agents/subagents/gsd-mempalace-curator.md": "34b7476d313bfda4", + "agents/subagents/gsd-mempalace-curator.yaml": "28dd095c371ada3b", + "agents/subagents/gsd-nyquist-auditor.md": "5e6335c615801f1c", + "agents/subagents/gsd-nyquist-auditor.yaml": "48ad300fa775dd5d", + "agents/subagents/gsd-pattern-mapper.md": "eb2d4e838aaeb10c", + "agents/subagents/gsd-pattern-mapper.yaml": "d1e537e77f953fd4", + "agents/subagents/gsd-phase-researcher.md": "fd8438c9b723e4a9", + "agents/subagents/gsd-phase-researcher.yaml": "7633c8e82617e7cc", + "agents/subagents/gsd-plan-checker.md": "6352e38b8546a89e", + "agents/subagents/gsd-plan-checker.yaml": "8295181071121db8", + "agents/subagents/gsd-planner.md": "df8c0ab06f9221f3", + "agents/subagents/gsd-planner.yaml": "2e83ee194bcd7fbd", + "agents/subagents/gsd-project-researcher.md": "50528d1cc19f2fc1", + "agents/subagents/gsd-project-researcher.yaml": "ce12586b0347e2dc", + "agents/subagents/gsd-research-synthesizer.md": "eefaa59ac7af07cf", + "agents/subagents/gsd-research-synthesizer.yaml": "898104ab3bb0b81a", + "agents/subagents/gsd-roadmapper.md": "2edc9acf9c0c7515", + "agents/subagents/gsd-roadmapper.yaml": "679772cb14f3a015", + "agents/subagents/gsd-security-auditor.md": "853395f34fdb22ab", + "agents/subagents/gsd-security-auditor.yaml": "fe8a4345cc13571d", + "agents/subagents/gsd-ui-auditor.md": "41011606fddfd8f8", + "agents/subagents/gsd-ui-auditor.yaml": "3fc98c1d9e8f10fd", + "agents/subagents/gsd-ui-checker.md": "f78439004e23919a", + "agents/subagents/gsd-ui-checker.yaml": "c42316006654fae3", + "agents/subagents/gsd-ui-researcher.md": "3b22f426c83ac46c", + "agents/subagents/gsd-ui-researcher.yaml": "15e215874bc2c37d", + "agents/subagents/gsd-user-profiler.md": "c16f94e09e433394", + "agents/subagents/gsd-user-profiler.yaml": "645826a29d079159", + "agents/subagents/gsd-verifier.md": "3345b59f7a3b8de3", + "agents/subagents/gsd-verifier.yaml": "2d2bd6b37626f382", + "gsd-core/CHANGELOG.md": "e141e3fb369ff712", + "gsd-core/VERSION": "562368b20a64be95", + "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", + "gsd-core/bin/gsd-tools.cjs": "b7968e3e3af00249", + "gsd-core/bin/gsd_run": "62d9b647ede212e6", + "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", + "gsd-core/bin/shared/config-schema.manifest.json": "67e4addbfd248a7c", + "gsd-core/bin/shared/model-catalog.json": "dbe26e683236d8c6", + "gsd-core/bin/shared/runtime-aliases.manifest.json": "f6c8b3af10dcfdc4", + "gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904", + "gsd-core/contexts/dev.md": "dcb0de9dce33cf41", + "gsd-core/contexts/research.md": "b3285d8e7209cc3b", + "gsd-core/contexts/review.md": "dc578fdd74bbea11", + "gsd-core/references/agent-contracts.md": "ff65e633c656c0d2", + "gsd-core/references/ai-evals.md": "b5afa786b938671e", + "gsd-core/references/ai-frameworks.md": "f827de93dde124eb", + "gsd-core/references/artifact-types.md": "a6d2e1f9453ffbf5", + "gsd-core/references/autonomous-smart-discuss.md": "2fc710cde0ec7785", + "gsd-core/references/checkpoints.md": "6aa620c6ca38bdf0", + "gsd-core/references/common-bug-patterns.md": "780145be56352626", + "gsd-core/references/context-budget.md": "f1ce57bf418824af", + "gsd-core/references/continuation-format.md": "ce73e57d053ab8a7", + "gsd-core/references/debugger-philosophy.md": "0466f95a3d6bfcd3", + "gsd-core/references/decimal-phase-calculation.md": "46b5ba045852c474", + "gsd-core/references/doc-conflict-engine.md": "67d019d23e17f934", + "gsd-core/references/domain-probes.md": "62d23ed1992c48a9", + "gsd-core/references/edge-probe-fixtures/01-round-half-even/expected-coverage.json": "72d1e29cedc854ec", + "gsd-core/references/edge-probe-fixtures/01-round-half-even/requirements.json": "fbc1b355d8625eeb", + "gsd-core/references/edge-probe-fixtures/02-merge-intervals/expected-coverage.json": "fad67dcc8294f6da", + "gsd-core/references/edge-probe-fixtures/02-merge-intervals/requirements.json": "30a78ee9ce3473ea", + "gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/expected-coverage.json": "66dd60957fee45f0", + "gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/requirements.json": "47fca61f076835fa", + "gsd-core/references/edge-probe-fixtures/04-money-rounding/expected-coverage.json": "72d1e29cedc854ec", + "gsd-core/references/edge-probe-fixtures/04-money-rounding/requirements.json": "80f04f5c04fb24cf", + "gsd-core/references/edge-probe-fixtures/05-list-dedupe/expected-coverage.json": "fad67dcc8294f6da", + "gsd-core/references/edge-probe-fixtures/05-list-dedupe/requirements.json": "d38147adb0e5b342", + "gsd-core/references/edge-probe-fixtures/06-resolved-mixed/expected-coverage.json": "bc552c01939bf4f8", + "gsd-core/references/edge-probe-fixtures/06-resolved-mixed/requirements.json": "30a78ee9ce3473ea", + "gsd-core/references/edge-probe-fixtures/06-resolved-mixed/resolutions.json": "688ec62c13e08afe", + "gsd-core/references/edge-probe.md": "5687eba25a078561", + "gsd-core/references/execute-mvp-tdd.md": "a98a270a7ab126bc", + "gsd-core/references/execute-phase-between-wave-reset.md": "3ad96ca0f7fee37e", + "gsd-core/references/execute-phase-context-guard.md": "982006c1f3364242", + "gsd-core/references/execute-phase-wave-guard.md": "de9ac22cead4cfd8", + "gsd-core/references/executor-examples.md": "ba59243ed45c8ab1", + "gsd-core/references/few-shot-examples/plan-checker.md": "2574808188ac9de4", + "gsd-core/references/few-shot-examples/verifier.md": "5badee4560b14ae8", + "gsd-core/references/gate-prompts.md": "e69f5993ab944d80", + "gsd-core/references/gates.md": "7dc9fd3a3d6217c6", + "gsd-core/references/git-integration.md": "77bf9dff38b2c9d4", + "gsd-core/references/git-planning-commit.md": "f897a15ebfc3f5a7", + "gsd-core/references/ios-scaffold.md": "5ef0cb7e0fac891f", + "gsd-core/references/loop-hook-dispatch.md": "32e5dfb4dba76987", + "gsd-core/references/mandatory-initial-read.md": "fe59abce693717cf", + "gsd-core/references/model-profile-resolution.md": "f32bb05102839767", + "gsd-core/references/model-profiles.md": "d3513252c5de5465", + "gsd-core/references/mvp-concepts.md": "72f7e8b1f8ae7118", + "gsd-core/references/phase-argument-parsing.md": "e5bbb985f3bc3e34", + "gsd-core/references/planner-antipatterns.md": "7ed54ec1e2cc54ac", + "gsd-core/references/planner-chunked.md": "79fe674221e738e6", + "gsd-core/references/planner-gap-closure.md": "76bee257911413e7", + "gsd-core/references/planner-graphify-auto-update.md": "6aeffd9097e25e22", + "gsd-core/references/planner-guidance.md": "96486cac2f7885e6", + "gsd-core/references/planner-human-verify-mode.md": "56d05e841630b3f4", + "gsd-core/references/planner-interface-context.md": "b28fa3da6ae739a8", + "gsd-core/references/planner-load-graph-context.md": "ca7a7af3f35ae61b", + "gsd-core/references/planner-mvp-mode.md": "ec33050db81101a8", + "gsd-core/references/planner-reviews.md": "dda0193a0fbd4947", + "gsd-core/references/planner-revision.md": "86ba8a511f081f05", + "gsd-core/references/planner-source-audit.md": "7de5bdb07232ce0b", + "gsd-core/references/planning-config.md": "1c3a3aae2ae89e83", + "gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json": "f10df472f2846cc6", + "gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json": "31e8a781eeffe020", + "gsd-core/references/prohibition-probe-fixtures/03-multi-prohibition/expected.json": "70a532a7cc1b6ae8", + "gsd-core/references/prohibition-probe.md": "605dc3f5a118ff3b", + "gsd-core/references/project-skills-discovery.md": "c155e03dce8dc3c2", + "gsd-core/references/questioning.md": "a8c988cab05f4651", + "gsd-core/references/research-documentation-lookup.md": "c070007d1d72ab71", + "gsd-core/references/research-philosophy.md": "62930e66cc979c1a", + "gsd-core/references/research-verification-protocol.md": "9c38c9d9a687e679", + "gsd-core/references/revision-loop.md": "e55ff32dd98c63df", + "gsd-core/references/scout-codebase.md": "ba266ecc18fbf172", + "gsd-core/references/security-asvs-levels.md": "4774fac3b94b6ca8", + "gsd-core/references/skeleton-template.md": "528691d1f0efa878", + "gsd-core/references/sketch-interactivity.md": "7d982fe877e1e1cc", + "gsd-core/references/sketch-theme-system.md": "33e2e96e450456f8", + "gsd-core/references/sketch-tooling.md": "df6c4f24c1c27611", + "gsd-core/references/sketch-variant-patterns.md": "66c197aa4fb52810", + "gsd-core/references/spidr-splitting.md": "074ac154c0e4f906", + "gsd-core/references/tdd.md": "e4708ede157478b6", + "gsd-core/references/thinking-models-debug.md": "2da61022b16c4e7c", + "gsd-core/references/thinking-models-execution.md": "dcc650a8b5f3e049", + "gsd-core/references/thinking-models-planning.md": "7e19462313fa028f", + "gsd-core/references/thinking-models-research.md": "5f6bf3f3b889c6e4", + "gsd-core/references/thinking-models-verification.md": "a71a933d51ca3d8d", + "gsd-core/references/thinking-partner.md": "41069529ef776e39", + "gsd-core/references/ui-brand.md": "48717bcfcd63bd27", + "gsd-core/references/universal-anti-patterns.md": "6a1245050b21df01", + "gsd-core/references/untrusted-input-boundary.md": "d33b80d4d348599a", + "gsd-core/references/user-profiling.md": "b50416fe57c1b321", + "gsd-core/references/user-story-template.md": "0cc50e06a144ff8a", + "gsd-core/references/verification-overrides.md": "8213de9bd62283b6", + "gsd-core/references/verification-patterns.md": "72f4d4814fdae9a8", + "gsd-core/references/verify-mvp-mode.md": "534bdc7f2432903a", + "gsd-core/references/workstream-flag.md": "ca99ca79e716f0f5", + "gsd-core/references/worktree-branch-check.md": "21d9c31bf6542b93", + "gsd-core/references/worktree-path-safety.md": "3c8d74756f9b16a8", + "gsd-core/templates/AI-SPEC.md": "efa1f8354bd3a24b", + "gsd-core/templates/DEBUG.md": "a13470b82b1935e7", + "gsd-core/templates/README.md": "93d3426fc64e2c12", + "gsd-core/templates/SECURITY.md": "b628f7f1c6d2328f", + "gsd-core/templates/UAT.md": "9e296471b97ebcec", + "gsd-core/templates/UI-SPEC.md": "20ca56a4e3e21f01", + "gsd-core/templates/VALIDATION.md": "f53e0ca061d3528e", + "gsd-core/templates/claude-md.md": "d1d333e4b963c0d2", + "gsd-core/templates/codebase/architecture.md": "6be88214162fdd89", + "gsd-core/templates/codebase/concerns.md": "efa26d1fb5132f25", + "gsd-core/templates/codebase/conventions.md": "c2e07698dad6b364", + "gsd-core/templates/codebase/integrations.md": "39bd23c71eedd564", + "gsd-core/templates/codebase/stack.md": "116e7e67dd87ddec", + "gsd-core/templates/codebase/structure.md": "222997133232a6f5", + "gsd-core/templates/codebase/testing.md": "76abff7f2050c9ea", + "gsd-core/templates/config.json": "1473dff11272d3c4", + "gsd-core/templates/context.md": "69b01e7909ea3f66", + "gsd-core/templates/continue-here.md": "f522a51b6895fba8", + "gsd-core/templates/copilot-instructions.md": "aea34bc52ff548ea", + "gsd-core/templates/debug-subagent-prompt.md": "920656683dedb869", + "gsd-core/templates/dev-preferences.md": "88d0a65ec0993a3a", + "gsd-core/templates/discovery.md": "9a0e0935cc825dbc", + "gsd-core/templates/discussion-log.md": "cac1b48ec0f4dcb8", + "gsd-core/templates/milestone-archive.md": "591b6decdc0c0e51", + "gsd-core/templates/milestone.md": "74d2f750ae9f4a9c", + "gsd-core/templates/phase-prompt.md": "b811bf951092df2f", + "gsd-core/templates/planner-subagent-prompt.md": "ebf29dbb27042370", + "gsd-core/templates/project.md": "ab4af6becca2bb23", + "gsd-core/templates/requirements.md": "a44de4c2f146e473", + "gsd-core/templates/research-project/ARCHITECTURE.md": "746b9ef791d758b0", + "gsd-core/templates/research-project/FEATURES.md": "f2b800de5df91b0f", + "gsd-core/templates/research-project/PITFALLS.md": "3ef75fa768422eec", + "gsd-core/templates/research-project/STACK.md": "82c85799ac4dd344", + "gsd-core/templates/research-project/SUMMARY.md": "dceb2f346388839d", + "gsd-core/templates/research.md": "88ce0920417091d0", + "gsd-core/templates/retrospective.md": "03981e30dd760103", + "gsd-core/templates/roadmap.md": "e4e35a9eb5dd4d4f", + "gsd-core/templates/spec.md": "26d55bce940f0288", + "gsd-core/templates/state.md": "4d123aa6cea167fe", + "gsd-core/templates/summary-complex.md": "a5e40574fd8894dc", + "gsd-core/templates/summary-minimal.md": "7d09b5e709e2e67c", + "gsd-core/templates/summary-standard.md": "e8d9cf4a8377cdff", + "gsd-core/templates/summary.md": "23c40f6503b3ea98", + "gsd-core/templates/user-profile.md": "20749f23e4c413fc", + "gsd-core/templates/user-setup.md": "78b7d718b6e8d67c", + "gsd-core/templates/verification-report.md": "dd5faa6254183731", + "gsd-core/workflows/_runtime-launcher.snippet.sh": "c0994e00f976dbfb", + "gsd-core/workflows/add-backlog.md": "1153d35058e3e24f", + "gsd-core/workflows/add-phase.md": "b98c2fc51cd123ba", + "gsd-core/workflows/add-tests.md": "2871f7e6409ba89e", + "gsd-core/workflows/add-todo.md": "3b913840c1f490b2", + "gsd-core/workflows/ai-integration-phase.md": "ad2ff20091d1c2c0", + "gsd-core/workflows/analyze-dependencies.md": "52942af10f140717", + "gsd-core/workflows/audit-fix.md": "eaead7ea85761e5b", + "gsd-core/workflows/audit-milestone.md": "fe7185bb70eafdb9", + "gsd-core/workflows/audit-uat.md": "ca6f1f0ef174f793", + "gsd-core/workflows/autonomous.md": "ba6828223ba13c1f", + "gsd-core/workflows/check-todos.md": "1e3026abe782bd36", + "gsd-core/workflows/cleanup.md": "5cf0772521c192a4", + "gsd-core/workflows/code-review-fix.md": "b442c670f8c1fe93", + "gsd-core/workflows/code-review.md": "1bfa3bf542e30c5e", + "gsd-core/workflows/complete-milestone.md": "8a4cfb41a4de5a99", + "gsd-core/workflows/debug.md": "da62e7a62c113b29", + "gsd-core/workflows/diagnose-issues.md": "b1fd597cb9420c33", + "gsd-core/workflows/discovery-phase.md": "3ba7cfb89fb1e761", + "gsd-core/workflows/discuss-phase-assumptions.md": "a365fdca7f7281ad", + "gsd-core/workflows/discuss-phase-power.md": "0841f7dc6e9a054a", + "gsd-core/workflows/discuss-phase.md": "d0da99d5c9c09c42", + "gsd-core/workflows/discuss-phase/modes/advisor.md": "c7d4a3ff91655d24", + "gsd-core/workflows/discuss-phase/modes/all.md": "fa70d79066562e54", + "gsd-core/workflows/discuss-phase/modes/analyze.md": "da0788f3be7f8105", + "gsd-core/workflows/discuss-phase/modes/auto.md": "3333b280afd68321", + "gsd-core/workflows/discuss-phase/modes/batch.md": "6946597770e2d448", + "gsd-core/workflows/discuss-phase/modes/chain.md": "ca3cc1ddfd19cd9d", + "gsd-core/workflows/discuss-phase/modes/default.md": "67d1b67f61f03966", + "gsd-core/workflows/discuss-phase/modes/power.md": "fdc7a728eaaa2261", + "gsd-core/workflows/discuss-phase/modes/text.md": "da6d45207da4a988", + "gsd-core/workflows/discuss-phase/templates/checkpoint.json": "e3bc3dca49db59eb", + "gsd-core/workflows/discuss-phase/templates/context.md": "6cd929e989fe2b0f", + "gsd-core/workflows/discuss-phase/templates/discussion-log.md": "1bbd7703f11128e1", + "gsd-core/workflows/do.md": "152e9c5c512f79cb", + "gsd-core/workflows/docs-update.md": "806ada831b961116", + "gsd-core/workflows/edit-phase.md": "e5624ac6e3f8bef5", + "gsd-core/workflows/eval-review.md": "dfcfb4f8ce031fae", + "gsd-core/workflows/execute-phase.md": "7bfec3ab2f9f1106", + "gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md": "8898e0ea533cc643", + "gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md": "7ebb7d1af6082028", + "gsd-core/workflows/execute-phase/steps/post-merge-gate.md": "abd2aca069c04a80", + "gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md": "be84efbd71e1513e", + "gsd-core/workflows/execute-plan.md": "0cda7b15d42b6d6f", + "gsd-core/workflows/explore.md": "11523b829cc67c29", + "gsd-core/workflows/extract-learnings.md": "a2e4836abdeb1fdb", + "gsd-core/workflows/fast.md": "94136fb570d20a9d", + "gsd-core/workflows/forensics.md": "76bea6e254652c2c", + "gsd-core/workflows/graduation.md": "00d1b7fe7ed8578b", + "gsd-core/workflows/health.md": "e3cb8ff4e01e9e53", + "gsd-core/workflows/help.md": "5d040504b9ab35e3", + "gsd-core/workflows/help/modes/brief.md": "5fe077fbbdb5d9f7", + "gsd-core/workflows/help/modes/default.md": "cc8ef97de634c995", + "gsd-core/workflows/help/modes/full.md": "26b7c72f1a12a80f", + "gsd-core/workflows/help/modes/topic.md": "d7c60d488810fbd6", + "gsd-core/workflows/import.md": "97861d522319d56e", + "gsd-core/workflows/inbox.md": "437f981ef9ae7b26", + "gsd-core/workflows/ingest-docs.md": "77d9103d8d5432f3", + "gsd-core/workflows/insert-phase.md": "621f9e75725542b1", + "gsd-core/workflows/list-phase-assumptions.md": "53dd4b69536c1bc3", + "gsd-core/workflows/list-seeds.md": "0a466d04df300854", + "gsd-core/workflows/list-workspaces.md": "6e5c402000090d7b", + "gsd-core/workflows/manager.md": "a31f7e255dd7d01d", + "gsd-core/workflows/map-codebase.md": "582bddef590de9c6", + "gsd-core/workflows/milestone-summary.md": "f956783f7d063963", + "gsd-core/workflows/mvp-phase.md": "bfa668107be972ed", + "gsd-core/workflows/new-milestone.md": "6d07ca2fc7aec3a4", + "gsd-core/workflows/new-project.md": "69d356f41ea36aa2", + "gsd-core/workflows/new-workspace.md": "fdd63a9adf030cb1", + "gsd-core/workflows/next.md": "48bd4b5d54f0d1f8", + "gsd-core/workflows/node-repair.md": "07a1628e5a1ff96b", + "gsd-core/workflows/note.md": "5a99eb396c744619", + "gsd-core/workflows/pause-work.md": "7bcbdf27ba957c8b", + "gsd-core/workflows/plan-milestone-gaps.md": "02fee851c82e3b25", + "gsd-core/workflows/plan-phase.md": "f210c67e41f89f4a", + "gsd-core/workflows/plan-review-convergence.md": "10007f8382864bcd", + "gsd-core/workflows/plant-seed.md": "7b795d7a1b4c9f06", + "gsd-core/workflows/pr-branch.md": "f2a35833fe784a53", + "gsd-core/workflows/profile-user.md": "fc41a92a13e7778b", + "gsd-core/workflows/progress.md": "057699b34b6198f7", + "gsd-core/workflows/quick.md": "d1f1fe161cd49775", + "gsd-core/workflows/reapply-patches.md": "7915e40411490bbe", + "gsd-core/workflows/remove-phase.md": "d030f80ca0df4fa9", + "gsd-core/workflows/remove-workspace.md": "b8817a3a5907f5bc", + "gsd-core/workflows/resume-project.md": "b18b51fd15cbce95", + "gsd-core/workflows/review.md": "18ab093986814bb3", + "gsd-core/workflows/scan.md": "54ff1ff60041d065", + "gsd-core/workflows/secure-phase.md": "1d1c66ad9ea01bd2", + "gsd-core/workflows/session-report.md": "2e5b1205324ddefa", + "gsd-core/workflows/settings-advanced.md": "e7e50f99e4de3872", + "gsd-core/workflows/settings-integrations.md": "351c5a6d0d5e27ed", + "gsd-core/workflows/settings.md": "48d337eb7e3f141b", + "gsd-core/workflows/ship.md": "d26fb5d3e965642a", + "gsd-core/workflows/sketch-wrap-up.md": "19046704ae337d73", + "gsd-core/workflows/sketch.md": "e42a914206c152ef", + "gsd-core/workflows/spec-phase.md": "2aa0353147153776", + "gsd-core/workflows/spike-wrap-up.md": "3f3e7f0018284059", + "gsd-core/workflows/spike.md": "be2295fe2b32956f", + "gsd-core/workflows/stats.md": "01c24349370a0e6d", + "gsd-core/workflows/sync-skills.md": "b505e6f8331c0918", + "gsd-core/workflows/thread.md": "c26ca43fdf928d46", + "gsd-core/workflows/transition.md": "eee3435817fab185", + "gsd-core/workflows/ui-phase.md": "3dfb9f5161375035", + "gsd-core/workflows/ui-review.md": "1ad3654435000881", + "gsd-core/workflows/ultraplan-phase.md": "66c17ad2e555e262", + "gsd-core/workflows/undo.md": "96d2775f008b3a85", + "gsd-core/workflows/update.md": "6369691790864147", + "gsd-core/workflows/validate-phase.md": "50f37b705b6e44fb", + "gsd-core/workflows/verify-phase.md": "968d569ea4ef377f", + "gsd-core/workflows/verify-work.md": "6f9c666386cb6d7e", + "scripts/changeset/README.md": "86ff89331dfd94b2", + "scripts/changeset/cli.cjs": "68f92a344b199271", + "scripts/changeset/github-release-notes.cjs": "795677f0c009b132", + "scripts/changeset/lint.cjs": "0066faed159154f0", + "scripts/changeset/new.cjs": "4991e21fd17f5541", + "scripts/changeset/parse.cjs": "f9a949cbcab56445", + "scripts/changeset/render.cjs": "e47bc3e1587c3cae", + "scripts/changeset/serialize.cjs": "ac0b8fe6f87cdb0e", + "scripts/fix-slash-commands.cjs": "0519742531ff3529", + "scripts/lib/allowlist-ratchet.cjs": "ffaceaac3efc2660", + "scripts/lib/cli-exit.cjs": "612d0c372c75b7e7", + "skills/gsd-add-tests/SKILL.md": "217fc2587d3e7ec2", + "skills/gsd-ai-integration-phase/SKILL.md": "739a1376f6f56094", + "skills/gsd-audit-fix/SKILL.md": "ed585da70ba6da12", + "skills/gsd-audit-milestone/SKILL.md": "ad78e50cef304155", + "skills/gsd-audit-uat/SKILL.md": "b8e73cbb993a30e2", + "skills/gsd-autonomous/SKILL.md": "20b5483ce0cfac1b", + "skills/gsd-capture/SKILL.md": "d229a25109ab5f25", + "skills/gsd-cleanup/SKILL.md": "188302809e37f3a8", + "skills/gsd-code-review/SKILL.md": "f1e4c9d83d367715", + "skills/gsd-complete-milestone/SKILL.md": "d64cf8e60bf953d8", + "skills/gsd-config/SKILL.md": "234548c60b978332", + "skills/gsd-debug/SKILL.md": "71a61812bfd55246", + "skills/gsd-discuss-phase/SKILL.md": "77340d9f1fffa66f", + "skills/gsd-docs-update/SKILL.md": "05f4d618a938d3d4", + "skills/gsd-eval-review/SKILL.md": "6b32f5735b170d73", + "skills/gsd-execute-phase/SKILL.md": "8c92fb28c150b645", + "skills/gsd-explore/SKILL.md": "4ed4085d70b490b5", + "skills/gsd-extract-learnings/SKILL.md": "f16892f054247db9", + "skills/gsd-fast/SKILL.md": "dbf45fa1a88a9a16", + "skills/gsd-forensics/SKILL.md": "3077371ecd612e1d", + "skills/gsd-graphify/SKILL.md": "dc7e931f4acf8bfb", + "skills/gsd-health/SKILL.md": "9aa2cfcd42443dce", + "skills/gsd-help/SKILL.md": "b592e8ac7d2e512f", + "skills/gsd-import/SKILL.md": "8cdba84960d1e5fa", + "skills/gsd-inbox/SKILL.md": "955779a4b1df1c33", + "skills/gsd-ingest-docs/SKILL.md": "6366644e623ae9e4", + "skills/gsd-manager/SKILL.md": "8a11a1beb8e35a9c", + "skills/gsd-map-codebase/SKILL.md": "55dade835b0f4954", + "skills/gsd-mempalace-capture/SKILL.md": "3746fa9b34e20e71", + "skills/gsd-mempalace-recall/SKILL.md": "07b1b0e767da162c", + "skills/gsd-milestone-summary/SKILL.md": "3ea93ccaa0d4d967", + "skills/gsd-mvp-phase/SKILL.md": "d2f98f1e955797b6", + "skills/gsd-new-milestone/SKILL.md": "52c3cbe5b931985b", + "skills/gsd-new-project/SKILL.md": "d74e7583377e3563", + "skills/gsd-ns-context/SKILL.md": "89f5bffe6702c0c5", + "skills/gsd-ns-ideate/SKILL.md": "e0c0420f6878906a", + "skills/gsd-ns-manage/SKILL.md": "b7fbe29e45e75f54", + "skills/gsd-ns-project/SKILL.md": "1292d4a6a4280ce9", + "skills/gsd-ns-review/SKILL.md": "2ff62330379f947f", + "skills/gsd-ns-workflow/SKILL.md": "3f5fcfec9da730a2", + "skills/gsd-pause-work/SKILL.md": "95017c70ae9dca0d", + "skills/gsd-phase/SKILL.md": "31578c329cc2583e", + "skills/gsd-plan-phase/SKILL.md": "54798eddf30056b5", + "skills/gsd-plan-review-convergence/SKILL.md": "0b549738d41d59b5", + "skills/gsd-pr-branch/SKILL.md": "67f468db29ff2cf1", + "skills/gsd-profile-user/SKILL.md": "19a1d3aba57f6c7c", + "skills/gsd-progress/SKILL.md": "cc46a92b92b989ce", + "skills/gsd-quick/SKILL.md": "e6aa7d96326fc874", + "skills/gsd-resume-work/SKILL.md": "6e83a416db8253e1", + "skills/gsd-review-backlog/SKILL.md": "ffc6216cfb6c3002", + "skills/gsd-review/SKILL.md": "c248b415238b49ee", + "skills/gsd-secure-phase/SKILL.md": "9aa4378c6371e5e4", + "skills/gsd-settings/SKILL.md": "dee7f5011c9a46f4", + "skills/gsd-ship/SKILL.md": "b2e10966b6781b95", + "skills/gsd-sketch/SKILL.md": "010431b46e681600", + "skills/gsd-spec-phase/SKILL.md": "23ff63ddf425c1c2", + "skills/gsd-spike/SKILL.md": "aa740909d20a82f2", + "skills/gsd-stats/SKILL.md": "a7e478f76e0b6db1", + "skills/gsd-surface/SKILL.md": "2f72da87138503aa", + "skills/gsd-thread/SKILL.md": "fb52a782c6e83e03", + "skills/gsd-ui-phase/SKILL.md": "1d7ed00d7970ed51", + "skills/gsd-ui-review/SKILL.md": "39b36951bafe1495", + "skills/gsd-ultraplan-phase/SKILL.md": "766e038ea49f1ca7", + "skills/gsd-undo/SKILL.md": "ce1c97ed1228ff6d", + "skills/gsd-update/SKILL.md": "5ecb292eca7e9563", + "skills/gsd-validate-phase/SKILL.md": "2995d65030d9e441", + "skills/gsd-verify-work/SKILL.md": "9268ea031159f121", + "skills/gsd-workspace/SKILL.md": "0ebfbc00112e3802", + "skills/gsd-workstreams/SKILL.md": "cd2ed0019f00302a" +} diff --git a/tests/fixtures/golden-install-parity/opencode.json b/tests/fixtures/golden-install-parity/opencode.json new file mode 100644 index 000000000..7727d0235 --- /dev/null +++ b/tests/fixtures/golden-install-parity/opencode.json @@ -0,0 +1,473 @@ +{ + ".gsd-profile": "0e716a5fef4e6dc1", + ".gsd/defaults.json": "560664b045e645cb", + "agents/gsd-advisor-researcher.md": "fc4eba63daf0b1ee", + "agents/gsd-ai-researcher.md": "fb47a65999c957d0", + "agents/gsd-assumptions-analyzer.md": "cc0dbbd41e0a77a5", + "agents/gsd-code-fixer.md": "aa353a2a42b27981", + "agents/gsd-code-reviewer.md": "70deeb7ac7caf384", + "agents/gsd-codebase-mapper.md": "b9fd529aca682ccb", + "agents/gsd-debug-session-manager.md": "09c805b7283633fd", + "agents/gsd-debugger.md": "610a3ff965f9fb6a", + "agents/gsd-doc-classifier.md": "cd4169adec56c26f", + "agents/gsd-doc-synthesizer.md": "47a8b52fe974a810", + "agents/gsd-doc-verifier.md": "65bbc2ae6b9ace29", + "agents/gsd-doc-writer.md": "b7acb2d8238c44c4", + "agents/gsd-domain-researcher.md": "71250e759ca9e723", + "agents/gsd-eval-auditor.md": "8975c9f3bca95bd5", + "agents/gsd-eval-planner.md": "60bddb70a937f796", + "agents/gsd-executor.md": "bcdc3f8bc334190a", + "agents/gsd-framework-selector.md": "1c0a10355e787675", + "agents/gsd-integration-checker.md": "69cfb2bbdfa56e28", + "agents/gsd-intel-updater.md": "f9ef2797738b7f09", + "agents/gsd-mempalace-curator.md": "aa7cf2c611057735", + "agents/gsd-nyquist-auditor.md": "b9b9e935503e63a5", + "agents/gsd-pattern-mapper.md": "7c6d1d9817a9c1e7", + "agents/gsd-phase-researcher.md": "c206f5e7333fb2c9", + "agents/gsd-plan-checker.md": "f2c99e85291ce71b", + "agents/gsd-planner.md": "7a49c1bee3578da4", + "agents/gsd-project-researcher.md": "c70f9cf0d5593f09", + "agents/gsd-research-synthesizer.md": "a534bc0eaf1ac03b", + "agents/gsd-roadmapper.md": "6b8bdf6015939351", + "agents/gsd-security-auditor.md": "404f38e52ba883ff", + "agents/gsd-ui-auditor.md": "b040c900e8de2368", + "agents/gsd-ui-checker.md": "b98b00e586610657", + "agents/gsd-ui-researcher.md": "7ff5c528bef6af09", + "agents/gsd-user-profiler.md": "d825b4c0a6431f8b", + "agents/gsd-verifier.md": "1e0ff1d8d15a48ab", + "command/gsd-add-tests.md": "b9cd93ed01945f75", + "command/gsd-ai-integration-phase.md": "9d4bc4dcce7f1ee0", + "command/gsd-audit-fix.md": "5615403843d5e491", + "command/gsd-audit-milestone.md": "4c700c081f3918ba", + "command/gsd-audit-uat.md": "47dce58d5823da8d", + "command/gsd-autonomous.md": "d6444c6d39585b07", + "command/gsd-capture.md": "a1085d451e138028", + "command/gsd-cleanup.md": "66b219076f1e2aa3", + "command/gsd-code-review.md": "3256032e666821b5", + "command/gsd-complete-milestone.md": "92ee70af4d6235bb", + "command/gsd-config.md": "11045b66313fb788", + "command/gsd-debug.md": "cb6ac21129dff67f", + "command/gsd-discuss-phase.md": "a2a59956edf33a2c", + "command/gsd-docs-update.md": "668979b6c56708c0", + "command/gsd-eval-review.md": "28499776a3fde3ad", + "command/gsd-execute-phase.md": "da51f21998c75175", + "command/gsd-explore.md": "7618219025e28c15", + "command/gsd-extract-learnings.md": "42fd40385c7a7a3b", + "command/gsd-fast.md": "d1fcda0719ae90be", + "command/gsd-forensics.md": "051270e8c76c1b13", + "command/gsd-graphify.md": "1a4ec781f74862e2", + "command/gsd-health.md": "66d298ed71d57cb0", + "command/gsd-help.md": "a26c6ba391002635", + "command/gsd-import.md": "e1bff34deb61c802", + "command/gsd-inbox.md": "cc850bab897aed1f", + "command/gsd-ingest-docs.md": "b47c812c247e58a4", + "command/gsd-manager.md": "86ece154b34b6d1b", + "command/gsd-map-codebase.md": "1fb678f8a17a380e", + "command/gsd-mempalace-capture.md": "08b9750f1b1d1ba6", + "command/gsd-mempalace-recall.md": "2316f1a950490845", + "command/gsd-milestone-summary.md": "86a827271bc647c5", + "command/gsd-mvp-phase.md": "d45fd76cb9cff3ef", + "command/gsd-new-milestone.md": "64c9dc73425a66a2", + "command/gsd-new-project.md": "0086f0de26002219", + "command/gsd-ns-context.md": "9a2b55b64f2e1e4b", + "command/gsd-ns-ideate.md": "27e7fcdc69eeeb5f", + "command/gsd-ns-manage.md": "eded59052ca4b240", + "command/gsd-ns-project.md": "91417cf7b76581dd", + "command/gsd-ns-review.md": "61fe33e28eb1b961", + "command/gsd-ns-workflow.md": "3352c292c27fe50b", + "command/gsd-pause-work.md": "bb5bf91a2e3e480e", + "command/gsd-phase.md": "6bcda1539f949d5a", + "command/gsd-plan-phase.md": "7ee44086b59862b9", + "command/gsd-plan-review-convergence.md": "79e141ca34edba9a", + "command/gsd-pr-branch.md": "31fca4f1d6c4ee62", + "command/gsd-profile-user.md": "725c14ae7203b5b6", + "command/gsd-progress.md": "001f2754c427a0a9", + "command/gsd-quick.md": "07c02ab7547aec8e", + "command/gsd-resume-work.md": "3ac18b2a8cc41066", + "command/gsd-review-backlog.md": "1e99d6a7a3806fa5", + "command/gsd-review.md": "e1aee41cc2d736b5", + "command/gsd-secure-phase.md": "b536ad6e68af3a26", + "command/gsd-settings.md": "a3d2cb48b06c9b90", + "command/gsd-ship.md": "062d0bb5656fa31b", + "command/gsd-sketch.md": "1af54bb16c370798", + "command/gsd-spec-phase.md": "b92bca076ca2c5c6", + "command/gsd-spike.md": "1b2fa4b468e831dc", + "command/gsd-stats.md": "51af934859f87623", + "command/gsd-surface.md": "26a5f0aab82c1c4a", + "command/gsd-thread.md": "ae3b000bee0ee1e0", + "command/gsd-ui-phase.md": "21c9c043d813dc0a", + "command/gsd-ui-review.md": "ef36603b519e8fe8", + "command/gsd-ultraplan-phase.md": "d18d13428ae3f8e6", + "command/gsd-undo.md": "dbbf9423ce795b89", + "command/gsd-update.md": "df2be717088fe8d0", + "command/gsd-validate-phase.md": "f320705816d725f3", + "command/gsd-verify-work.md": "f23fff8e6d71f704", + "command/gsd-workspace.md": "1e581bdb33bc8f55", + "command/gsd-workstreams.md": "5e57eed1881c3891", + "gsd-core/CHANGELOG.md": "e141e3fb369ff712", + "gsd-core/VERSION": "562368b20a64be95", + "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", + "gsd-core/bin/gsd-tools.cjs": "b7968e3e3af00249", + "gsd-core/bin/gsd_run": "62d9b647ede212e6", + "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", + "gsd-core/bin/shared/config-schema.manifest.json": "67e4addbfd248a7c", + "gsd-core/bin/shared/model-catalog.json": "dbe26e683236d8c6", + "gsd-core/bin/shared/runtime-aliases.manifest.json": "f6c8b3af10dcfdc4", + "gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904", + "gsd-core/contexts/dev.md": "dcb0de9dce33cf41", + "gsd-core/contexts/research.md": "b3285d8e7209cc3b", + "gsd-core/contexts/review.md": "dc578fdd74bbea11", + "gsd-core/references/agent-contracts.md": "ff65e633c656c0d2", + "gsd-core/references/ai-evals.md": "b5afa786b938671e", + "gsd-core/references/ai-frameworks.md": "425dd69c629230e7", + "gsd-core/references/artifact-types.md": "218c55caf8aff6df", + "gsd-core/references/autonomous-smart-discuss.md": "3986d58011bf9006", + "gsd-core/references/checkpoints.md": "9feb961f644afa96", + "gsd-core/references/common-bug-patterns.md": "780145be56352626", + "gsd-core/references/context-budget.md": "355826e667f9ccd1", + "gsd-core/references/continuation-format.md": "580287399ad3ba68", + "gsd-core/references/debugger-philosophy.md": "1cb61d4565081c88", + "gsd-core/references/decimal-phase-calculation.md": "46b5ba045852c474", + "gsd-core/references/doc-conflict-engine.md": "883d0a1b9d9ff96e", + "gsd-core/references/domain-probes.md": "762b965e84035b72", + "gsd-core/references/edge-probe-fixtures/01-round-half-even/expected-coverage.json": "72d1e29cedc854ec", + "gsd-core/references/edge-probe-fixtures/01-round-half-even/requirements.json": "fbc1b355d8625eeb", + "gsd-core/references/edge-probe-fixtures/02-merge-intervals/expected-coverage.json": "fad67dcc8294f6da", + "gsd-core/references/edge-probe-fixtures/02-merge-intervals/requirements.json": "30a78ee9ce3473ea", + "gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/expected-coverage.json": "66dd60957fee45f0", + "gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/requirements.json": "47fca61f076835fa", + "gsd-core/references/edge-probe-fixtures/04-money-rounding/expected-coverage.json": "72d1e29cedc854ec", + "gsd-core/references/edge-probe-fixtures/04-money-rounding/requirements.json": "80f04f5c04fb24cf", + "gsd-core/references/edge-probe-fixtures/05-list-dedupe/expected-coverage.json": "fad67dcc8294f6da", + "gsd-core/references/edge-probe-fixtures/05-list-dedupe/requirements.json": "d38147adb0e5b342", + "gsd-core/references/edge-probe-fixtures/06-resolved-mixed/expected-coverage.json": "bc552c01939bf4f8", + "gsd-core/references/edge-probe-fixtures/06-resolved-mixed/requirements.json": "30a78ee9ce3473ea", + "gsd-core/references/edge-probe-fixtures/06-resolved-mixed/resolutions.json": "688ec62c13e08afe", + "gsd-core/references/edge-probe.md": "47e6193c678eefe5", + "gsd-core/references/execute-mvp-tdd.md": "a98a270a7ab126bc", + "gsd-core/references/execute-phase-between-wave-reset.md": "3ad96ca0f7fee37e", + "gsd-core/references/execute-phase-context-guard.md": "a5a1058d35806a8e", + "gsd-core/references/execute-phase-wave-guard.md": "de9ac22cead4cfd8", + "gsd-core/references/executor-examples.md": "ba59243ed45c8ab1", + "gsd-core/references/few-shot-examples/plan-checker.md": "2574808188ac9de4", + "gsd-core/references/few-shot-examples/verifier.md": "5badee4560b14ae8", + "gsd-core/references/gate-prompts.md": "43e867720e736464", + "gsd-core/references/gates.md": "7dc9fd3a3d6217c6", + "gsd-core/references/git-integration.md": "fbdf814a3af9c051", + "gsd-core/references/git-planning-commit.md": "f897a15ebfc3f5a7", + "gsd-core/references/ios-scaffold.md": "5ef0cb7e0fac891f", + "gsd-core/references/loop-hook-dispatch.md": "32e5dfb4dba76987", + "gsd-core/references/mandatory-initial-read.md": "fe59abce693717cf", + "gsd-core/references/model-profile-resolution.md": "858c16730df68ac2", + "gsd-core/references/model-profiles.md": "9e160d4e754b6b9e", + "gsd-core/references/mvp-concepts.md": "3464783eaaef5c10", + "gsd-core/references/phase-argument-parsing.md": "e5bbb985f3bc3e34", + "gsd-core/references/planner-antipatterns.md": "2459c40bfc8a0ea8", + "gsd-core/references/planner-chunked.md": "79fe674221e738e6", + "gsd-core/references/planner-gap-closure.md": "76bee257911413e7", + "gsd-core/references/planner-graphify-auto-update.md": "1ed614dfba72f2a3", + "gsd-core/references/planner-guidance.md": "7fe925c09092476b", + "gsd-core/references/planner-human-verify-mode.md": "3a625b42d9cb93ee", + "gsd-core/references/planner-interface-context.md": "b28fa3da6ae739a8", + "gsd-core/references/planner-load-graph-context.md": "97f6e67b56c072c1", + "gsd-core/references/planner-mvp-mode.md": "2901bb0fdb156d5c", + "gsd-core/references/planner-reviews.md": "da39eace09a10743", + "gsd-core/references/planner-revision.md": "86ba8a511f081f05", + "gsd-core/references/planner-source-audit.md": "7de5bdb07232ce0b", + "gsd-core/references/planning-config.md": "4acefb3c71169c84", + "gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json": "f10df472f2846cc6", + "gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json": "31e8a781eeffe020", + "gsd-core/references/prohibition-probe-fixtures/03-multi-prohibition/expected.json": "70a532a7cc1b6ae8", + "gsd-core/references/prohibition-probe.md": "5892b08550d8444e", + "gsd-core/references/project-skills-discovery.md": "c2ba4b8beda7bbcc", + "gsd-core/references/questioning.md": "a083e5e3e16dd802", + "gsd-core/references/research-documentation-lookup.md": "c070007d1d72ab71", + "gsd-core/references/research-philosophy.md": "a602b41bfa081fdf", + "gsd-core/references/research-verification-protocol.md": "9c38c9d9a687e679", + "gsd-core/references/revision-loop.md": "e55ff32dd98c63df", + "gsd-core/references/scout-codebase.md": "ba266ecc18fbf172", + "gsd-core/references/security-asvs-levels.md": "4774fac3b94b6ca8", + "gsd-core/references/skeleton-template.md": "528691d1f0efa878", + "gsd-core/references/sketch-interactivity.md": "7d982fe877e1e1cc", + "gsd-core/references/sketch-theme-system.md": "33e2e96e450456f8", + "gsd-core/references/sketch-tooling.md": "df6c4f24c1c27611", + "gsd-core/references/sketch-variant-patterns.md": "66c197aa4fb52810", + "gsd-core/references/spidr-splitting.md": "074ac154c0e4f906", + "gsd-core/references/tdd.md": "e4708ede157478b6", + "gsd-core/references/thinking-models-debug.md": "2da61022b16c4e7c", + "gsd-core/references/thinking-models-execution.md": "dcc650a8b5f3e049", + "gsd-core/references/thinking-models-planning.md": "7e19462313fa028f", + "gsd-core/references/thinking-models-research.md": "5f6bf3f3b889c6e4", + "gsd-core/references/thinking-models-verification.md": "a71a933d51ca3d8d", + "gsd-core/references/thinking-partner.md": "827c1badf3e6df41", + "gsd-core/references/ui-brand.md": "48717bcfcd63bd27", + "gsd-core/references/universal-anti-patterns.md": "6a1245050b21df01", + "gsd-core/references/untrusted-input-boundary.md": "d33b80d4d348599a", + "gsd-core/references/user-profiling.md": "8e023a908d968af1", + "gsd-core/references/user-story-template.md": "0cc50e06a144ff8a", + "gsd-core/references/verification-overrides.md": "a3e2d5166d16a37b", + "gsd-core/references/verification-patterns.md": "3bd98d30eee6c5a1", + "gsd-core/references/verify-mvp-mode.md": "534bdc7f2432903a", + "gsd-core/references/workstream-flag.md": "225910640adbb76d", + "gsd-core/references/worktree-branch-check.md": "21d9c31bf6542b93", + "gsd-core/references/worktree-path-safety.md": "3c8d74756f9b16a8", + "gsd-core/templates/AI-SPEC.md": "24df5fe5ba34e367", + "gsd-core/templates/DEBUG.md": "a2eea97e7f1336bf", + "gsd-core/templates/README.md": "73d3c9689b6efbc9", + "gsd-core/templates/SECURITY.md": "b628f7f1c6d2328f", + "gsd-core/templates/UAT.md": "68d32d1fea14e184", + "gsd-core/templates/UI-SPEC.md": "20ca56a4e3e21f01", + "gsd-core/templates/VALIDATION.md": "6144951011cdca57", + "gsd-core/templates/claude-md.md": "a4f5e38984001194", + "gsd-core/templates/codebase/architecture.md": "282db635ba093b1a", + "gsd-core/templates/codebase/concerns.md": "e66c584daa636fc5", + "gsd-core/templates/codebase/conventions.md": "c5f0324730852701", + "gsd-core/templates/codebase/integrations.md": "39bd23c71eedd564", + "gsd-core/templates/codebase/stack.md": "116e7e67dd87ddec", + "gsd-core/templates/codebase/structure.md": "bb76f9538f2bc4e0", + "gsd-core/templates/codebase/testing.md": "76abff7f2050c9ea", + "gsd-core/templates/config.json": "1473dff11272d3c4", + "gsd-core/templates/context.md": "593d4e6d20fdde7c", + "gsd-core/templates/continue-here.md": "8b1dce4832c7922b", + "gsd-core/templates/copilot-instructions.md": "aea34bc52ff548ea", + "gsd-core/templates/debug-subagent-prompt.md": "8c18a89e25929d8e", + "gsd-core/templates/dev-preferences.md": "95048a71063d980b", + "gsd-core/templates/discovery.md": "e4ab738326eb70e0", + "gsd-core/templates/discussion-log.md": "da4e8e266efdc6c2", + "gsd-core/templates/milestone-archive.md": "591b6decdc0c0e51", + "gsd-core/templates/milestone.md": "74d2f750ae9f4a9c", + "gsd-core/templates/phase-prompt.md": "ee8880432b4d1566", + "gsd-core/templates/planner-subagent-prompt.md": "6c9f1b23ee3dc05f", + "gsd-core/templates/project.md": "e4a3b331be081e62", + "gsd-core/templates/requirements.md": "a44de4c2f146e473", + "gsd-core/templates/research-project/ARCHITECTURE.md": "746b9ef791d758b0", + "gsd-core/templates/research-project/FEATURES.md": "f2b800de5df91b0f", + "gsd-core/templates/research-project/PITFALLS.md": "3ef75fa768422eec", + "gsd-core/templates/research-project/STACK.md": "82c85799ac4dd344", + "gsd-core/templates/research-project/SUMMARY.md": "dceb2f346388839d", + "gsd-core/templates/research.md": "b454f96fdf8b1d3d", + "gsd-core/templates/retrospective.md": "03981e30dd760103", + "gsd-core/templates/roadmap.md": "e4e35a9eb5dd4d4f", + "gsd-core/templates/spec.md": "8734f0df4df3a34b", + "gsd-core/templates/state.md": "a45a134631efe3f9", + "gsd-core/templates/summary-complex.md": "a5e40574fd8894dc", + "gsd-core/templates/summary-minimal.md": "7d09b5e709e2e67c", + "gsd-core/templates/summary-standard.md": "e8d9cf4a8377cdff", + "gsd-core/templates/summary.md": "23c40f6503b3ea98", + "gsd-core/templates/user-profile.md": "52abe2af968e8533", + "gsd-core/templates/user-setup.md": "1da2382725db080f", + "gsd-core/templates/verification-report.md": "78ab9264ce63ed7e", + "gsd-core/workflows/_runtime-launcher.snippet.sh": "c0994e00f976dbfb", + "gsd-core/workflows/add-backlog.md": "b06846965aac2c53", + "gsd-core/workflows/add-phase.md": "226a281548498ae5", + "gsd-core/workflows/add-tests.md": "29a06d57250bc054", + "gsd-core/workflows/add-todo.md": "c98d2b68d0f0bf6d", + "gsd-core/workflows/ai-integration-phase.md": "bd7f0c25f1f52a95", + "gsd-core/workflows/analyze-dependencies.md": "77aff48f97fa6f1c", + "gsd-core/workflows/audit-fix.md": "c7878f1dff04adf1", + "gsd-core/workflows/audit-milestone.md": "99f0b884c9208dde", + "gsd-core/workflows/audit-uat.md": "e530acf37fd9c699", + "gsd-core/workflows/autonomous.md": "9a19deccc06a702e", + "gsd-core/workflows/check-todos.md": "42806d03eacf9ff6", + "gsd-core/workflows/cleanup.md": "daca0c4f1d531a02", + "gsd-core/workflows/code-review-fix.md": "618c850533bd5c9e", + "gsd-core/workflows/code-review.md": "e362de9cce43c8fc", + "gsd-core/workflows/complete-milestone.md": "f48a1771ebb15067", + "gsd-core/workflows/debug.md": "0e12f6f3589cc0ea", + "gsd-core/workflows/diagnose-issues.md": "d8c817d029ea186e", + "gsd-core/workflows/discovery-phase.md": "724408336596c50c", + "gsd-core/workflows/discuss-phase-assumptions.md": "42418e64617c5de3", + "gsd-core/workflows/discuss-phase-power.md": "5c0d1ca6abda0383", + "gsd-core/workflows/discuss-phase.md": "3b4b9908e57b329f", + "gsd-core/workflows/discuss-phase/modes/advisor.md": "f44a1979ba5841a3", + "gsd-core/workflows/discuss-phase/modes/all.md": "5ed71228ac96e728", + "gsd-core/workflows/discuss-phase/modes/analyze.md": "da0788f3be7f8105", + "gsd-core/workflows/discuss-phase/modes/auto.md": "69d274e077af5292", + "gsd-core/workflows/discuss-phase/modes/batch.md": "78f6ef61690acb66", + "gsd-core/workflows/discuss-phase/modes/chain.md": "b01b9974f762af76", + "gsd-core/workflows/discuss-phase/modes/default.md": "5d12f2593ae4a813", + "gsd-core/workflows/discuss-phase/modes/power.md": "799fc7e1283b568f", + "gsd-core/workflows/discuss-phase/modes/text.md": "daf23a04b101ab38", + "gsd-core/workflows/discuss-phase/templates/checkpoint.json": "e3bc3dca49db59eb", + "gsd-core/workflows/discuss-phase/templates/context.md": "9804674fccada405", + "gsd-core/workflows/discuss-phase/templates/discussion-log.md": "04e4feee14772275", + "gsd-core/workflows/do.md": "9464559b832bf9c0", + "gsd-core/workflows/docs-update.md": "93e969c3afb446a0", + "gsd-core/workflows/edit-phase.md": "ed948a400a0146c7", + "gsd-core/workflows/eval-review.md": "1ba1af74a43c07db", + "gsd-core/workflows/execute-phase.md": "54846f6fffad0cea", + "gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md": "a640b093a5a7b028", + "gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md": "7ebb7d1af6082028", + "gsd-core/workflows/execute-phase/steps/post-merge-gate.md": "8b42f5df1a2c231f", + "gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md": "be84efbd71e1513e", + "gsd-core/workflows/execute-plan.md": "7894850154836fcf", + "gsd-core/workflows/explore.md": "96ba55f53899a261", + "gsd-core/workflows/extract-learnings.md": "b8851263c2d28702", + "gsd-core/workflows/fast.md": "54fe93778b45a7eb", + "gsd-core/workflows/forensics.md": "706b3983dd315789", + "gsd-core/workflows/graduation.md": "7d03b353a0323cce", + "gsd-core/workflows/health.md": "1fad392ce1aec410", + "gsd-core/workflows/help.md": "5d040504b9ab35e3", + "gsd-core/workflows/help/modes/brief.md": "2e923b3ed635d495", + "gsd-core/workflows/help/modes/default.md": "b544113eb9aa54fa", + "gsd-core/workflows/help/modes/full.md": "4683429199900853", + "gsd-core/workflows/help/modes/topic.md": "a404990fcf2d7df7", + "gsd-core/workflows/import.md": "46337b37d375f4da", + "gsd-core/workflows/inbox.md": "a073396097c89c01", + "gsd-core/workflows/ingest-docs.md": "bbb4f5fb3ab75113", + "gsd-core/workflows/insert-phase.md": "a305657b04dc3f1f", + "gsd-core/workflows/list-phase-assumptions.md": "e7b5e35c9452e3b3", + "gsd-core/workflows/list-seeds.md": "f8a55d8f83a4492d", + "gsd-core/workflows/list-workspaces.md": "74aa5ac3cb9b00bf", + "gsd-core/workflows/manager.md": "e77230eb5a8a1c78", + "gsd-core/workflows/map-codebase.md": "40624186d001658f", + "gsd-core/workflows/milestone-summary.md": "0acae8647e544018", + "gsd-core/workflows/mvp-phase.md": "efeb5d11f5b10e73", + "gsd-core/workflows/new-milestone.md": "56ae3ae36ab32cef", + "gsd-core/workflows/new-project.md": "d3c0ce0e163d5048", + "gsd-core/workflows/new-workspace.md": "cfbe1d7f60f2bfe4", + "gsd-core/workflows/next.md": "83ccac8577032cb6", + "gsd-core/workflows/node-repair.md": "07a1628e5a1ff96b", + "gsd-core/workflows/note.md": "0d1374f2a2257858", + "gsd-core/workflows/pause-work.md": "c9f0b8826845dda7", + "gsd-core/workflows/plan-milestone-gaps.md": "5ec459734bf7570c", + "gsd-core/workflows/plan-phase.md": "1aea2bd181a782cf", + "gsd-core/workflows/plan-review-convergence.md": "4f884d793d72d3fd", + "gsd-core/workflows/plant-seed.md": "507e886d0f70d84c", + "gsd-core/workflows/pr-branch.md": "3abaa18246facdc3", + "gsd-core/workflows/profile-user.md": "6c16f62edaaebdc9", + "gsd-core/workflows/progress.md": "6b34c4542cfe90b0", + "gsd-core/workflows/quick.md": "47baa1c8712f1911", + "gsd-core/workflows/reapply-patches.md": "a6cfec7e2a0e7ee2", + "gsd-core/workflows/remove-phase.md": "863c58e99e9d630d", + "gsd-core/workflows/remove-workspace.md": "4ed03c52e5c7bd4d", + "gsd-core/workflows/resume-project.md": "15153ce5a4c38674", + "gsd-core/workflows/review.md": "dd336f71a0b8f228", + "gsd-core/workflows/scan.md": "4634caefa32a7307", + "gsd-core/workflows/secure-phase.md": "9616682fe23cf042", + "gsd-core/workflows/session-report.md": "2e5b1205324ddefa", + "gsd-core/workflows/settings-advanced.md": "6ea6dd988fefdab3", + "gsd-core/workflows/settings-integrations.md": "6bd6cccc1aff9bc9", + "gsd-core/workflows/settings.md": "4c24ab123703d769", + "gsd-core/workflows/ship.md": "5ffb515478d78cb6", + "gsd-core/workflows/sketch-wrap-up.md": "1a9f5b8e01a43506", + "gsd-core/workflows/sketch.md": "89acaae8de9bf8c9", + "gsd-core/workflows/spec-phase.md": "eb94ceb386b2a328", + "gsd-core/workflows/spike-wrap-up.md": "3609a57dbd8b5ac0", + "gsd-core/workflows/spike.md": "df52b9f31a72d204", + "gsd-core/workflows/stats.md": "598b1bb510ed0451", + "gsd-core/workflows/sync-skills.md": "7e2c138cdbef4282", + "gsd-core/workflows/thread.md": "c11265f0fa0a95d3", + "gsd-core/workflows/transition.md": "2d6739f730c3ea10", + "gsd-core/workflows/ui-phase.md": "38dac814d2d03d6f", + "gsd-core/workflows/ui-review.md": "3d972d3bd30527b3", + "gsd-core/workflows/ultraplan-phase.md": "11db7f58a45aca2e", + "gsd-core/workflows/undo.md": "0bba5e7f6196c894", + "gsd-core/workflows/update.md": "af51041a3172c523", + "gsd-core/workflows/validate-phase.md": "0fc5991f6d7c6c39", + "gsd-core/workflows/verify-phase.md": "d8999a0a1cd7b6de", + "gsd-core/workflows/verify-work.md": "52f6011634cff599", + "hooks/gsd-check-update-worker.js": "5882dbd41918c863", + "hooks/gsd-check-update.js": "5fb0027b7b76986c", + "hooks/gsd-config-reload.js": "17bf778d432b3d2a", + "hooks/gsd-context-monitor.js": "6afbef2291b68874", + "hooks/gsd-cursor-post-tool.js": "d61ee04f6ee7858c", + "hooks/gsd-cursor-session-start.js": "148b8ec4e2c97f00", + "hooks/gsd-ensure-canonical-path.js": "5f8be5b0a01a88ea", + "hooks/gsd-graphify-update.sh": "396ebda3c6705dc9", + "hooks/gsd-phase-boundary.sh": "6aa3ba9af3d465d9", + "hooks/gsd-prompt-guard.js": "4b08c2dce0233e2d", + "hooks/gsd-read-guard.js": "b602f88f046a7551", + "hooks/gsd-read-injection-scanner.js": "fb5730e37a300e69", + "hooks/gsd-session-state.sh": "b1496e6a5204a6df", + "hooks/gsd-statusline.js": "a0e7b01ec5012940", + "hooks/gsd-update-banner.js": "74817c820b7a4ec1", + "hooks/gsd-validate-commit.sh": "14d3d966c74dc310", + "hooks/gsd-workflow-guard.js": "e22b9fb57f0e64f6", + "hooks/gsd-worktree-path-guard.js": "3df1fd5409d358f3", + "hooks/lib/git-cmd.js": "268ba15992ca0b23", + "hooks/lib/gsd-graphify-rebuild.sh": "66af89601074d2a9", + "hooks/managed-hooks-registry.cjs": "763730ef31e5fd1c", + "opencode.json": "13151e97ff23c1aa", + "package.json": "dbf8353f77358bc1", + "scripts/changeset/README.md": "86ff89331dfd94b2", + "scripts/changeset/cli.cjs": "68f92a344b199271", + "scripts/changeset/github-release-notes.cjs": "795677f0c009b132", + "scripts/changeset/lint.cjs": "0066faed159154f0", + "scripts/changeset/new.cjs": "4991e21fd17f5541", + "scripts/changeset/parse.cjs": "f9a949cbcab56445", + "scripts/changeset/render.cjs": "e47bc3e1587c3cae", + "scripts/changeset/serialize.cjs": "ac0b8fe6f87cdb0e", + "scripts/fix-slash-commands.cjs": "0519742531ff3529", + "scripts/lib/allowlist-ratchet.cjs": "ffaceaac3efc2660", + "scripts/lib/cli-exit.cjs": "612d0c372c75b7e7", + "skills/gsd-add-tests/SKILL.md": "f60794b90b48cbac", + "skills/gsd-ai-integration-phase/SKILL.md": "868ca7c86ef61033", + "skills/gsd-audit-fix/SKILL.md": "740d255df04b6e93", + "skills/gsd-audit-milestone/SKILL.md": "68b57f54877f35a9", + "skills/gsd-audit-uat/SKILL.md": "d73e6e175a711ed9", + "skills/gsd-autonomous/SKILL.md": "b3191d241afe2c87", + "skills/gsd-capture/SKILL.md": "880a3b965d53c689", + "skills/gsd-cleanup/SKILL.md": "e65d36bc64d7ce56", + "skills/gsd-code-review/SKILL.md": "f5c5d139d9f58e30", + "skills/gsd-complete-milestone/SKILL.md": "f794594de6a2e6c8", + "skills/gsd-config/SKILL.md": "6bcfbc815e5abddd", + "skills/gsd-debug/SKILL.md": "fbf869a73b318bfc", + "skills/gsd-discuss-phase/SKILL.md": "b86f39c16163c3ca", + "skills/gsd-docs-update/SKILL.md": "b0377eebbf71f802", + "skills/gsd-eval-review/SKILL.md": "a0da1d2b8b0dec62", + "skills/gsd-execute-phase/SKILL.md": "6d93b3fa856aef8f", + "skills/gsd-explore/SKILL.md": "79a05b06d9854d56", + "skills/gsd-extract-learnings/SKILL.md": "039975449139eb77", + "skills/gsd-fast/SKILL.md": "5b39ef1a6ad40b93", + "skills/gsd-forensics/SKILL.md": "1e0be6b8862ac549", + "skills/gsd-graphify/SKILL.md": "578621183849fe03", + "skills/gsd-health/SKILL.md": "b0bb84935cfbdd61", + "skills/gsd-help/SKILL.md": "326eb69ee18b4265", + "skills/gsd-import/SKILL.md": "23ac1e4e73e175d4", + "skills/gsd-inbox/SKILL.md": "3b2dd319d91dbab4", + "skills/gsd-ingest-docs/SKILL.md": "8f294b4894f782a5", + "skills/gsd-manager/SKILL.md": "cf40f237b4f20aa7", + "skills/gsd-map-codebase/SKILL.md": "6d74ea41e29ff035", + "skills/gsd-mempalace-capture/SKILL.md": "260e1dabc7014ba2", + "skills/gsd-mempalace-recall/SKILL.md": "045d85c8cab1afcc", + "skills/gsd-milestone-summary/SKILL.md": "175366bb500b5e64", + "skills/gsd-mvp-phase/SKILL.md": "0531d0e907af41ce", + "skills/gsd-new-milestone/SKILL.md": "45688741cc4bab41", + "skills/gsd-new-project/SKILL.md": "feef18d69466fdf9", + "skills/gsd-ns-context/SKILL.md": "110fb2227bcde535", + "skills/gsd-ns-ideate/SKILL.md": "d093d59a8519decc", + "skills/gsd-ns-manage/SKILL.md": "2756981de613022e", + "skills/gsd-ns-project/SKILL.md": "757976d48e8b7a26", + "skills/gsd-ns-review/SKILL.md": "680bdd33c882b049", + "skills/gsd-ns-workflow/SKILL.md": "68effe2fc2c65317", + "skills/gsd-pause-work/SKILL.md": "c7e9ba4f242c4648", + "skills/gsd-phase/SKILL.md": "ee78c0c56c814d84", + "skills/gsd-plan-phase/SKILL.md": "6abcc3a1568a6bb9", + "skills/gsd-plan-review-convergence/SKILL.md": "15ab9274bbb733a7", + "skills/gsd-pr-branch/SKILL.md": "8fa5a8fa217fe913", + "skills/gsd-profile-user/SKILL.md": "3e6155a64523d59f", + "skills/gsd-progress/SKILL.md": "1dfd9327510e500c", + "skills/gsd-quick/SKILL.md": "a9a971a0d471b64f", + "skills/gsd-resume-work/SKILL.md": "aa6a7d9e88fe26b6", + "skills/gsd-review-backlog/SKILL.md": "27a9dbfe92e5d04c", + "skills/gsd-review/SKILL.md": "9fe549f07cf4da07", + "skills/gsd-secure-phase/SKILL.md": "b37a0dbae84dd74a", + "skills/gsd-settings/SKILL.md": "d72a537765bf690a", + "skills/gsd-ship/SKILL.md": "fc88192bae429756", + "skills/gsd-sketch/SKILL.md": "e3fd03e727bc20df", + "skills/gsd-spec-phase/SKILL.md": "332028ed34b85b1f", + "skills/gsd-spike/SKILL.md": "9303cd74a8bf0741", + "skills/gsd-stats/SKILL.md": "2af976632e239069", + "skills/gsd-surface/SKILL.md": "ea512089d0e1d057", + "skills/gsd-thread/SKILL.md": "9ff9979a8213dbf8", + "skills/gsd-ui-phase/SKILL.md": "5eee1bdd13640252", + "skills/gsd-ui-review/SKILL.md": "c99d6725326bfbc4", + "skills/gsd-ultraplan-phase/SKILL.md": "5c28ee1f65be343b", + "skills/gsd-undo/SKILL.md": "4a5d3472c7da4cc9", + "skills/gsd-update/SKILL.md": "268677bd0089ea9b", + "skills/gsd-validate-phase/SKILL.md": "8d29201527e7d9d4", + "skills/gsd-verify-work/SKILL.md": "bea3cee3b2f5170e", + "skills/gsd-workspace/SKILL.md": "f3c295e6b7c2e38a", + "skills/gsd-workstreams/SKILL.md": "44c62598142837b9" +} diff --git a/tests/fixtures/golden-install-parity/qwen.json b/tests/fixtures/golden-install-parity/qwen.json new file mode 100644 index 000000000..19e6d7dca --- /dev/null +++ b/tests/fixtures/golden-install-parity/qwen.json @@ -0,0 +1,404 @@ +{ + ".gsd-profile": "0e716a5fef4e6dc1", + ".gsd/defaults.json": "560664b045e645cb", + "agents/gsd-advisor-researcher.md": "5a0c65ccc25ddfbe", + "agents/gsd-ai-researcher.md": "97fdf624a1c6c324", + "agents/gsd-assumptions-analyzer.md": "a8982fc704f7ec81", + "agents/gsd-code-fixer.md": "f6da5df6c2cc1b14", + "agents/gsd-code-reviewer.md": "0f586b04f8628cc2", + "agents/gsd-codebase-mapper.md": "397ae2efb6e00487", + "agents/gsd-debug-session-manager.md": "c88ad88c7a9fff8b", + "agents/gsd-debugger.md": "dc5a6e749f132d67", + "agents/gsd-doc-classifier.md": "5de2a66c751b2d58", + "agents/gsd-doc-synthesizer.md": "8e6fb8220f777022", + "agents/gsd-doc-verifier.md": "3767850df78f4a36", + "agents/gsd-doc-writer.md": "7d9e39254bba203a", + "agents/gsd-domain-researcher.md": "d4c07520f650ffd5", + "agents/gsd-eval-auditor.md": "8d70a305103219ac", + "agents/gsd-eval-planner.md": "03448fc9c5774b56", + "agents/gsd-executor.md": "b672a6f59a043f9e", + "agents/gsd-framework-selector.md": "ea9981d65d6b3429", + "agents/gsd-integration-checker.md": "599ae0380d5218fc", + "agents/gsd-intel-updater.md": "22cc46f64ad4c336", + "agents/gsd-mempalace-curator.md": "576b8d0db51bc462", + "agents/gsd-nyquist-auditor.md": "61be7d5ed9aeb520", + "agents/gsd-pattern-mapper.md": "9ab071c3ffc1cf46", + "agents/gsd-phase-researcher.md": "e1df6362eba320a5", + "agents/gsd-plan-checker.md": "9e5a435b15b9274a", + "agents/gsd-planner.md": "d75b6e16d5338166", + "agents/gsd-project-researcher.md": "d8140745d468c79a", + "agents/gsd-research-synthesizer.md": "94365f77f72611f4", + "agents/gsd-roadmapper.md": "5dd0803e15660e85", + "agents/gsd-security-auditor.md": "f1554fb939be79fc", + "agents/gsd-ui-auditor.md": "4d8388dfe33496b1", + "agents/gsd-ui-checker.md": "7a383f6a3fbba34b", + "agents/gsd-ui-researcher.md": "67596ca5ee2f4547", + "agents/gsd-user-profiler.md": "ca3bf75581f211a0", + "agents/gsd-verifier.md": "82b7967e24c2065b", + "gsd-core/CHANGELOG.md": "e141e3fb369ff712", + "gsd-core/VERSION": "562368b20a64be95", + "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", + "gsd-core/bin/gsd-tools.cjs": "b7968e3e3af00249", + "gsd-core/bin/gsd_run": "62d9b647ede212e6", + "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", + "gsd-core/bin/shared/config-schema.manifest.json": "67e4addbfd248a7c", + "gsd-core/bin/shared/model-catalog.json": "dbe26e683236d8c6", + "gsd-core/bin/shared/runtime-aliases.manifest.json": "f6c8b3af10dcfdc4", + "gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904", + "gsd-core/contexts/dev.md": "dcb0de9dce33cf41", + "gsd-core/contexts/research.md": "b3285d8e7209cc3b", + "gsd-core/contexts/review.md": "dc578fdd74bbea11", + "gsd-core/references/agent-contracts.md": "ff65e633c656c0d2", + "gsd-core/references/ai-evals.md": "b5afa786b938671e", + "gsd-core/references/ai-frameworks.md": "f827de93dde124eb", + "gsd-core/references/artifact-types.md": "8bd01fd75a2ba70e", + "gsd-core/references/autonomous-smart-discuss.md": "2fc710cde0ec7785", + "gsd-core/references/checkpoints.md": "046171320f816346", + "gsd-core/references/common-bug-patterns.md": "780145be56352626", + "gsd-core/references/context-budget.md": "a1351dd40ef8691f", + "gsd-core/references/continuation-format.md": "580287399ad3ba68", + "gsd-core/references/debugger-philosophy.md": "0466f95a3d6bfcd3", + "gsd-core/references/decimal-phase-calculation.md": "46b5ba045852c474", + "gsd-core/references/doc-conflict-engine.md": "883d0a1b9d9ff96e", + "gsd-core/references/domain-probes.md": "762b965e84035b72", + "gsd-core/references/edge-probe-fixtures/01-round-half-even/expected-coverage.json": "72d1e29cedc854ec", + "gsd-core/references/edge-probe-fixtures/01-round-half-even/requirements.json": "fbc1b355d8625eeb", + "gsd-core/references/edge-probe-fixtures/02-merge-intervals/expected-coverage.json": "fad67dcc8294f6da", + "gsd-core/references/edge-probe-fixtures/02-merge-intervals/requirements.json": "30a78ee9ce3473ea", + "gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/expected-coverage.json": "66dd60957fee45f0", + "gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/requirements.json": "47fca61f076835fa", + "gsd-core/references/edge-probe-fixtures/04-money-rounding/expected-coverage.json": "72d1e29cedc854ec", + "gsd-core/references/edge-probe-fixtures/04-money-rounding/requirements.json": "80f04f5c04fb24cf", + "gsd-core/references/edge-probe-fixtures/05-list-dedupe/expected-coverage.json": "fad67dcc8294f6da", + "gsd-core/references/edge-probe-fixtures/05-list-dedupe/requirements.json": "d38147adb0e5b342", + "gsd-core/references/edge-probe-fixtures/06-resolved-mixed/expected-coverage.json": "bc552c01939bf4f8", + "gsd-core/references/edge-probe-fixtures/06-resolved-mixed/requirements.json": "30a78ee9ce3473ea", + "gsd-core/references/edge-probe-fixtures/06-resolved-mixed/resolutions.json": "688ec62c13e08afe", + "gsd-core/references/edge-probe.md": "5687eba25a078561", + "gsd-core/references/execute-mvp-tdd.md": "a98a270a7ab126bc", + "gsd-core/references/execute-phase-between-wave-reset.md": "8c2e5a596401dd33", + "gsd-core/references/execute-phase-context-guard.md": "a5a1058d35806a8e", + "gsd-core/references/execute-phase-wave-guard.md": "41d8123374d3838b", + "gsd-core/references/executor-examples.md": "ba59243ed45c8ab1", + "gsd-core/references/few-shot-examples/plan-checker.md": "2574808188ac9de4", + "gsd-core/references/few-shot-examples/verifier.md": "5badee4560b14ae8", + "gsd-core/references/gate-prompts.md": "099c8d52e3562336", + "gsd-core/references/gates.md": "7dc9fd3a3d6217c6", + "gsd-core/references/git-integration.md": "bd249d9024c39c0d", + "gsd-core/references/git-planning-commit.md": "f897a15ebfc3f5a7", + "gsd-core/references/ios-scaffold.md": "5ef0cb7e0fac891f", + "gsd-core/references/loop-hook-dispatch.md": "32e5dfb4dba76987", + "gsd-core/references/mandatory-initial-read.md": "fe59abce693717cf", + "gsd-core/references/model-profile-resolution.md": "f32bb05102839767", + "gsd-core/references/model-profiles.md": "8e209cb8ec89b0fb", + "gsd-core/references/mvp-concepts.md": "3464783eaaef5c10", + "gsd-core/references/phase-argument-parsing.md": "e5bbb985f3bc3e34", + "gsd-core/references/planner-antipatterns.md": "7ed54ec1e2cc54ac", + "gsd-core/references/planner-chunked.md": "79fe674221e738e6", + "gsd-core/references/planner-gap-closure.md": "76bee257911413e7", + "gsd-core/references/planner-graphify-auto-update.md": "1ed614dfba72f2a3", + "gsd-core/references/planner-guidance.md": "140b1266eeb096cb", + "gsd-core/references/planner-human-verify-mode.md": "ee0edddeed8eb946", + "gsd-core/references/planner-interface-context.md": "b28fa3da6ae739a8", + "gsd-core/references/planner-load-graph-context.md": "3f6f5ee62d86f72d", + "gsd-core/references/planner-mvp-mode.md": "ec33050db81101a8", + "gsd-core/references/planner-reviews.md": "da39eace09a10743", + "gsd-core/references/planner-revision.md": "86ba8a511f081f05", + "gsd-core/references/planner-source-audit.md": "7de5bdb07232ce0b", + "gsd-core/references/planning-config.md": "32430b855023bfdb", + "gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json": "f10df472f2846cc6", + "gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json": "31e8a781eeffe020", + "gsd-core/references/prohibition-probe-fixtures/03-multi-prohibition/expected.json": "70a532a7cc1b6ae8", + "gsd-core/references/prohibition-probe.md": "52872d5393ea9daf", + "gsd-core/references/project-skills-discovery.md": "955696ea2864c826", + "gsd-core/references/questioning.md": "a8c988cab05f4651", + "gsd-core/references/research-documentation-lookup.md": "c070007d1d72ab71", + "gsd-core/references/research-philosophy.md": "62930e66cc979c1a", + "gsd-core/references/research-verification-protocol.md": "9c38c9d9a687e679", + "gsd-core/references/revision-loop.md": "e55ff32dd98c63df", + "gsd-core/references/scout-codebase.md": "ba266ecc18fbf172", + "gsd-core/references/security-asvs-levels.md": "4774fac3b94b6ca8", + "gsd-core/references/skeleton-template.md": "528691d1f0efa878", + "gsd-core/references/sketch-interactivity.md": "7d982fe877e1e1cc", + "gsd-core/references/sketch-theme-system.md": "33e2e96e450456f8", + "gsd-core/references/sketch-tooling.md": "df6c4f24c1c27611", + "gsd-core/references/sketch-variant-patterns.md": "66c197aa4fb52810", + "gsd-core/references/spidr-splitting.md": "074ac154c0e4f906", + "gsd-core/references/tdd.md": "e4708ede157478b6", + "gsd-core/references/thinking-models-debug.md": "2da61022b16c4e7c", + "gsd-core/references/thinking-models-execution.md": "dcc650a8b5f3e049", + "gsd-core/references/thinking-models-planning.md": "7e19462313fa028f", + "gsd-core/references/thinking-models-research.md": "5f6bf3f3b889c6e4", + "gsd-core/references/thinking-models-verification.md": "a71a933d51ca3d8d", + "gsd-core/references/thinking-partner.md": "827c1badf3e6df41", + "gsd-core/references/ui-brand.md": "48717bcfcd63bd27", + "gsd-core/references/universal-anti-patterns.md": "6a1245050b21df01", + "gsd-core/references/untrusted-input-boundary.md": "d33b80d4d348599a", + "gsd-core/references/user-profiling.md": "b50416fe57c1b321", + "gsd-core/references/user-story-template.md": "0cc50e06a144ff8a", + "gsd-core/references/verification-overrides.md": "a3e2d5166d16a37b", + "gsd-core/references/verification-patterns.md": "72f4d4814fdae9a8", + "gsd-core/references/verify-mvp-mode.md": "534bdc7f2432903a", + "gsd-core/references/workstream-flag.md": "59804937da1ddaa9", + "gsd-core/references/worktree-branch-check.md": "3fc21ae9462c35c8", + "gsd-core/references/worktree-path-safety.md": "b4111595eabec186", + "gsd-core/templates/AI-SPEC.md": "24df5fe5ba34e367", + "gsd-core/templates/DEBUG.md": "57bd61bfd1d98e7e", + "gsd-core/templates/README.md": "f4825d4fa594f2b1", + "gsd-core/templates/SECURITY.md": "b628f7f1c6d2328f", + "gsd-core/templates/UAT.md": "68d32d1fea14e184", + "gsd-core/templates/UI-SPEC.md": "20ca56a4e3e21f01", + "gsd-core/templates/VALIDATION.md": "6144951011cdca57", + "gsd-core/templates/claude-md.md": "ec5972ab31c0f5d0", + "gsd-core/templates/codebase/architecture.md": "6be88214162fdd89", + "gsd-core/templates/codebase/concerns.md": "efa26d1fb5132f25", + "gsd-core/templates/codebase/conventions.md": "c2e07698dad6b364", + "gsd-core/templates/codebase/integrations.md": "39bd23c71eedd564", + "gsd-core/templates/codebase/stack.md": "116e7e67dd87ddec", + "gsd-core/templates/codebase/structure.md": "fe32daf7235d2a93", + "gsd-core/templates/codebase/testing.md": "76abff7f2050c9ea", + "gsd-core/templates/config.json": "1473dff11272d3c4", + "gsd-core/templates/context.md": "69b01e7909ea3f66", + "gsd-core/templates/continue-here.md": "f522a51b6895fba8", + "gsd-core/templates/copilot-instructions.md": "aea34bc52ff548ea", + "gsd-core/templates/debug-subagent-prompt.md": "8c18a89e25929d8e", + "gsd-core/templates/dev-preferences.md": "95048a71063d980b", + "gsd-core/templates/discovery.md": "e4ab738326eb70e0", + "gsd-core/templates/discussion-log.md": "cac1b48ec0f4dcb8", + "gsd-core/templates/milestone-archive.md": "591b6decdc0c0e51", + "gsd-core/templates/milestone.md": "74d2f750ae9f4a9c", + "gsd-core/templates/phase-prompt.md": "213ccd947451ff2b", + "gsd-core/templates/planner-subagent-prompt.md": "6c9f1b23ee3dc05f", + "gsd-core/templates/project.md": "1f687c7a88381080", + "gsd-core/templates/requirements.md": "a44de4c2f146e473", + "gsd-core/templates/research-project/ARCHITECTURE.md": "746b9ef791d758b0", + "gsd-core/templates/research-project/FEATURES.md": "f2b800de5df91b0f", + "gsd-core/templates/research-project/PITFALLS.md": "3ef75fa768422eec", + "gsd-core/templates/research-project/STACK.md": "82c85799ac4dd344", + "gsd-core/templates/research-project/SUMMARY.md": "dceb2f346388839d", + "gsd-core/templates/research.md": "fa6dfb2ff2e8d273", + "gsd-core/templates/retrospective.md": "03981e30dd760103", + "gsd-core/templates/roadmap.md": "e4e35a9eb5dd4d4f", + "gsd-core/templates/spec.md": "7dc900c355098d8b", + "gsd-core/templates/state.md": "73e424b8c70b765c", + "gsd-core/templates/summary-complex.md": "a5e40574fd8894dc", + "gsd-core/templates/summary-minimal.md": "7d09b5e709e2e67c", + "gsd-core/templates/summary-standard.md": "e8d9cf4a8377cdff", + "gsd-core/templates/summary.md": "23c40f6503b3ea98", + "gsd-core/templates/user-profile.md": "20749f23e4c413fc", + "gsd-core/templates/user-setup.md": "78b7d718b6e8d67c", + "gsd-core/templates/verification-report.md": "dd5faa6254183731", + "gsd-core/workflows/_runtime-launcher.snippet.sh": "c0994e00f976dbfb", + "gsd-core/workflows/add-backlog.md": "4b75a93042406fb8", + "gsd-core/workflows/add-phase.md": "e660b7f2793e6057", + "gsd-core/workflows/add-tests.md": "258d0a918a40f2b4", + "gsd-core/workflows/add-todo.md": "53acd2035bee5cbb", + "gsd-core/workflows/ai-integration-phase.md": "ce4775b1ea73c8ec", + "gsd-core/workflows/analyze-dependencies.md": "77aff48f97fa6f1c", + "gsd-core/workflows/audit-fix.md": "75f9d02e6924913c", + "gsd-core/workflows/audit-milestone.md": "49d1978340cf89ef", + "gsd-core/workflows/audit-uat.md": "b38b4e18e2abd51b", + "gsd-core/workflows/autonomous.md": "aa104d2eec3f112f", + "gsd-core/workflows/check-todos.md": "7fc4a39f3e83dde7", + "gsd-core/workflows/cleanup.md": "804c5d481279f008", + "gsd-core/workflows/code-review-fix.md": "7d6caed073170b2a", + "gsd-core/workflows/code-review.md": "ca4a87c143bb2d55", + "gsd-core/workflows/complete-milestone.md": "d72e6131a14244da", + "gsd-core/workflows/debug.md": "6000ef61c0a67d84", + "gsd-core/workflows/diagnose-issues.md": "1aa7b1f3c4cbe15d", + "gsd-core/workflows/discovery-phase.md": "6161c60d752d0058", + "gsd-core/workflows/discuss-phase-assumptions.md": "6360526c14233193", + "gsd-core/workflows/discuss-phase-power.md": "290c0d83d783f9f6", + "gsd-core/workflows/discuss-phase.md": "b5731ee756a9a724", + "gsd-core/workflows/discuss-phase/modes/advisor.md": "1bcf72db1553bbf2", + "gsd-core/workflows/discuss-phase/modes/all.md": "fa70d79066562e54", + "gsd-core/workflows/discuss-phase/modes/analyze.md": "da0788f3be7f8105", + "gsd-core/workflows/discuss-phase/modes/auto.md": "9b87eb70ef459efa", + "gsd-core/workflows/discuss-phase/modes/batch.md": "6946597770e2d448", + "gsd-core/workflows/discuss-phase/modes/chain.md": "2a6782b674cf4ca6", + "gsd-core/workflows/discuss-phase/modes/default.md": "67d1b67f61f03966", + "gsd-core/workflows/discuss-phase/modes/power.md": "fdc7a728eaaa2261", + "gsd-core/workflows/discuss-phase/modes/text.md": "6914c661b95859ee", + "gsd-core/workflows/discuss-phase/templates/checkpoint.json": "e3bc3dca49db59eb", + "gsd-core/workflows/discuss-phase/templates/context.md": "6cd929e989fe2b0f", + "gsd-core/workflows/discuss-phase/templates/discussion-log.md": "1bbd7703f11128e1", + "gsd-core/workflows/do.md": "1f0dc350abac269a", + "gsd-core/workflows/docs-update.md": "12433c1e3cf9e40e", + "gsd-core/workflows/edit-phase.md": "4ba06a05cb29f3b7", + "gsd-core/workflows/eval-review.md": "0ab8368c8edf91fb", + "gsd-core/workflows/execute-phase.md": "9222c85add7d9ae3", + "gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md": "9320986ccf0e8a60", + "gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md": "7ebb7d1af6082028", + "gsd-core/workflows/execute-phase/steps/post-merge-gate.md": "4116471249699255", + "gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md": "be84efbd71e1513e", + "gsd-core/workflows/execute-plan.md": "73f47e5a189cd27d", + "gsd-core/workflows/explore.md": "42f73d9fc38429da", + "gsd-core/workflows/extract-learnings.md": "1d0a3cbf17159316", + "gsd-core/workflows/fast.md": "54fe93778b45a7eb", + "gsd-core/workflows/forensics.md": "ac80bcbe9bef175f", + "gsd-core/workflows/graduation.md": "7398492ff69af5e7", + "gsd-core/workflows/health.md": "de79abe3b83a0252", + "gsd-core/workflows/help.md": "5d040504b9ab35e3", + "gsd-core/workflows/help/modes/brief.md": "2e923b3ed635d495", + "gsd-core/workflows/help/modes/default.md": "cef457f954a9991e", + "gsd-core/workflows/help/modes/full.md": "c4e359b86de6b99d", + "gsd-core/workflows/help/modes/topic.md": "bd2e4cc8e460f5bd", + "gsd-core/workflows/import.md": "5c0e3e97b0a59a06", + "gsd-core/workflows/inbox.md": "91aac6360e1a8672", + "gsd-core/workflows/ingest-docs.md": "9cbe45380eed4a0e", + "gsd-core/workflows/insert-phase.md": "080d2f56cf0021ab", + "gsd-core/workflows/list-phase-assumptions.md": "2a6b6a5acfb7742c", + "gsd-core/workflows/list-seeds.md": "e8d404a07c612d46", + "gsd-core/workflows/list-workspaces.md": "0bda9b42bb5c509b", + "gsd-core/workflows/manager.md": "f2ae9c83a80cc690", + "gsd-core/workflows/map-codebase.md": "8ce1e33f45827202", + "gsd-core/workflows/milestone-summary.md": "886ec00825fb1442", + "gsd-core/workflows/mvp-phase.md": "b3561a8ab1b26a4c", + "gsd-core/workflows/new-milestone.md": "8ec6c5fbc26d8227", + "gsd-core/workflows/new-project.md": "6c1440959e637d2d", + "gsd-core/workflows/new-workspace.md": "9b41c1b5c0196936", + "gsd-core/workflows/next.md": "80f2feb243818c90", + "gsd-core/workflows/node-repair.md": "07a1628e5a1ff96b", + "gsd-core/workflows/note.md": "42b66686b2c102cb", + "gsd-core/workflows/pause-work.md": "54f1c0a9e79e2c59", + "gsd-core/workflows/plan-milestone-gaps.md": "1b820f4e70acce86", + "gsd-core/workflows/plan-phase.md": "fcc0bfeadf4aa5c4", + "gsd-core/workflows/plan-review-convergence.md": "b809588bff4f48b5", + "gsd-core/workflows/plant-seed.md": "e3949fcbcf5d375f", + "gsd-core/workflows/pr-branch.md": "95850381230787ed", + "gsd-core/workflows/profile-user.md": "07e634c1dab106fc", + "gsd-core/workflows/progress.md": "eb2dafb8e419b784", + "gsd-core/workflows/quick.md": "21c35985860be6bc", + "gsd-core/workflows/reapply-patches.md": "4a93b90cbe70a2f1", + "gsd-core/workflows/remove-phase.md": "17c46fdcef27df2d", + "gsd-core/workflows/remove-workspace.md": "1d34788a9b2272d2", + "gsd-core/workflows/resume-project.md": "bba3250afbea8f09", + "gsd-core/workflows/review.md": "3d7c8df929053b79", + "gsd-core/workflows/scan.md": "514d3eba81565193", + "gsd-core/workflows/secure-phase.md": "a7e8edb0e5f48256", + "gsd-core/workflows/session-report.md": "2e5b1205324ddefa", + "gsd-core/workflows/settings-advanced.md": "8691bf2e1502eb19", + "gsd-core/workflows/settings-integrations.md": "8110932b38057f2f", + "gsd-core/workflows/settings.md": "1bd9dc8b2e7402e6", + "gsd-core/workflows/ship.md": "e10d21edabf201a7", + "gsd-core/workflows/sketch-wrap-up.md": "7630ee69ab22462d", + "gsd-core/workflows/sketch.md": "68c337d92c79dc9b", + "gsd-core/workflows/spec-phase.md": "dbe5f223d7bc026c", + "gsd-core/workflows/spike-wrap-up.md": "96b8244988d651fd", + "gsd-core/workflows/spike.md": "52fcd95f7b343232", + "gsd-core/workflows/stats.md": "7ffa072290ebcae3", + "gsd-core/workflows/sync-skills.md": "b505e6f8331c0918", + "gsd-core/workflows/thread.md": "d85a53d03a3167e0", + "gsd-core/workflows/transition.md": "1f0a1478d75d89ec", + "gsd-core/workflows/ui-phase.md": "1c91323daf451053", + "gsd-core/workflows/ui-review.md": "f1f82d8257e910cc", + "gsd-core/workflows/ultraplan-phase.md": "edcaa0b29f942df0", + "gsd-core/workflows/undo.md": "791e0bf96d9a057f", + "gsd-core/workflows/update.md": "baae1a977fbeba49", + "gsd-core/workflows/validate-phase.md": "0e153ccb3bdb9dda", + "gsd-core/workflows/verify-phase.md": "33a17f66c8291096", + "gsd-core/workflows/verify-work.md": "8a4157d179fce9c4", + "hooks/gsd-check-update-worker.js": "74edb6f6b1010c85", + "hooks/gsd-check-update.js": "81962511d619d038", + "hooks/gsd-config-reload.js": "e5b430ced986ea68", + "hooks/gsd-context-monitor.js": "8e55e33027fb54d4", + "hooks/gsd-cursor-post-tool.js": "dd1b12f795de8d72", + "hooks/gsd-cursor-session-start.js": "a93095ac609a3ea6", + "hooks/gsd-ensure-canonical-path.js": "4c1626ed20ab7a75", + "hooks/gsd-graphify-update.sh": "396ebda3c6705dc9", + "hooks/gsd-phase-boundary.sh": "6aa3ba9af3d465d9", + "hooks/gsd-prompt-guard.js": "4b08c2dce0233e2d", + "hooks/gsd-read-guard.js": "9d1b227b6fcb6dba", + "hooks/gsd-read-injection-scanner.js": "e48bc03685078bc7", + "hooks/gsd-session-state.sh": "b1496e6a5204a6df", + "hooks/gsd-statusline.js": "b9d07c3acc630b5d", + "hooks/gsd-update-banner.js": "d3228b9e674296b4", + "hooks/gsd-validate-commit.sh": "14d3d966c74dc310", + "hooks/gsd-workflow-guard.js": "6faa8f81812a8b5d", + "hooks/gsd-worktree-path-guard.js": "d9054ef9ec469312", + "hooks/lib/git-cmd.js": "268ba15992ca0b23", + "hooks/lib/gsd-graphify-rebuild.sh": "66af89601074d2a9", + "hooks/managed-hooks-registry.cjs": "a57697c1ae4ac163", + "package.json": "dbf8353f77358bc1", + "scripts/changeset/README.md": "86ff89331dfd94b2", + "scripts/changeset/cli.cjs": "68f92a344b199271", + "scripts/changeset/github-release-notes.cjs": "795677f0c009b132", + "scripts/changeset/lint.cjs": "0066faed159154f0", + "scripts/changeset/new.cjs": "4991e21fd17f5541", + "scripts/changeset/parse.cjs": "f9a949cbcab56445", + "scripts/changeset/render.cjs": "e47bc3e1587c3cae", + "scripts/changeset/serialize.cjs": "ac0b8fe6f87cdb0e", + "scripts/fix-slash-commands.cjs": "0519742531ff3529", + "scripts/lib/allowlist-ratchet.cjs": "ffaceaac3efc2660", + "scripts/lib/cli-exit.cjs": "612d0c372c75b7e7", + "skills/gsd-ns-context/SKILL.md": "654c71262d91650c", + "skills/gsd-ns-context/skills/docs-update/SKILL.md": "3d41e81ec571de60", + "skills/gsd-ns-context/skills/extract-learnings/SKILL.md": "8d38e55e5f8b774b", + "skills/gsd-ns-context/skills/graphify/SKILL.md": "ac3661e6e576188b", + "skills/gsd-ns-context/skills/map-codebase/SKILL.md": "5009595dbde69739", + "skills/gsd-ns-context/skills/mempalace-capture/SKILL.md": "e9b66db79fce0a2f", + "skills/gsd-ns-context/skills/mempalace-recall/SKILL.md": "9bfa9e76c7a10e26", + "skills/gsd-ns-ideate/SKILL.md": "4a0d2691054b23e5", + "skills/gsd-ns-ideate/skills/capture/SKILL.md": "05fa421c95fbad2d", + "skills/gsd-ns-ideate/skills/explore/SKILL.md": "7ee5c455f37093be", + "skills/gsd-ns-ideate/skills/sketch/SKILL.md": "e531174c131acc85", + "skills/gsd-ns-ideate/skills/spec-phase/SKILL.md": "7e4dfa2070b7d9d1", + "skills/gsd-ns-ideate/skills/spike/SKILL.md": "04d5f50d8d4a1c1a", + "skills/gsd-ns-manage/SKILL.md": "b6a1480f20b33bfa", + "skills/gsd-ns-manage/skills/cleanup/SKILL.md": "ea0ddedf403f309d", + "skills/gsd-ns-manage/skills/config/SKILL.md": "09a21c976161e5ae", + "skills/gsd-ns-manage/skills/health/SKILL.md": "ab21123ba99ec255", + "skills/gsd-ns-manage/skills/help/SKILL.md": "af56ff76cbd5e248", + "skills/gsd-ns-manage/skills/inbox/SKILL.md": "d05a099c0be02c60", + "skills/gsd-ns-manage/skills/manager/SKILL.md": "1370d93741e47828", + "skills/gsd-ns-manage/skills/pause-work/SKILL.md": "c7ea1020a3b4d06d", + "skills/gsd-ns-manage/skills/pr-branch/SKILL.md": "c5e26f2c6dff1355", + "skills/gsd-ns-manage/skills/resume-work/SKILL.md": "55df1b1adb14530c", + "skills/gsd-ns-manage/skills/settings/SKILL.md": "6d9fbddb0b00fd46", + "skills/gsd-ns-manage/skills/ship/SKILL.md": "bd6cb3b46d57123f", + "skills/gsd-ns-manage/skills/stats/SKILL.md": "5403a46852241fa8", + "skills/gsd-ns-manage/skills/surface/SKILL.md": "0b3fe299b6544de7", + "skills/gsd-ns-manage/skills/thread/SKILL.md": "a76c70c368f82dee", + "skills/gsd-ns-manage/skills/undo/SKILL.md": "0ad1218f55c94e4b", + "skills/gsd-ns-manage/skills/update/SKILL.md": "530b4206d729b56d", + "skills/gsd-ns-manage/skills/workspace/SKILL.md": "047c3f4247bc869e", + "skills/gsd-ns-manage/skills/workstreams/SKILL.md": "f4e54cb9b1ca0442", + "skills/gsd-ns-project/SKILL.md": "a58b3170197968be", + "skills/gsd-ns-project/skills/audit-milestone/SKILL.md": "edb6a0cc6305d724", + "skills/gsd-ns-project/skills/complete-milestone/SKILL.md": "f6799a5a713be7bb", + "skills/gsd-ns-project/skills/import/SKILL.md": "88f83921a85e536b", + "skills/gsd-ns-project/skills/ingest-docs/SKILL.md": "eb85e654917a503a", + "skills/gsd-ns-project/skills/milestone-summary/SKILL.md": "33d51a325d314f3a", + "skills/gsd-ns-project/skills/new-milestone/SKILL.md": "ac99ffb8a966fe9d", + "skills/gsd-ns-project/skills/new-project/SKILL.md": "9d5c27ebfa2c4746", + "skills/gsd-ns-project/skills/profile-user/SKILL.md": "78ca46045e85223f", + "skills/gsd-ns-project/skills/review-backlog/SKILL.md": "1708aab6cb919223", + "skills/gsd-ns-review/SKILL.md": "7badceb8627d8154", + "skills/gsd-ns-review/skills/audit-fix/SKILL.md": "d47c757ad361e503", + "skills/gsd-ns-review/skills/audit-uat/SKILL.md": "f3cb11adb6dab54f", + "skills/gsd-ns-review/skills/code-review/SKILL.md": "e862dc22848a6d24", + "skills/gsd-ns-review/skills/debug/SKILL.md": "42de44884d97d86d", + "skills/gsd-ns-review/skills/eval-review/SKILL.md": "b6b6059061388363", + "skills/gsd-ns-review/skills/forensics/SKILL.md": "4ef11f4cf902186f", + "skills/gsd-ns-review/skills/review/SKILL.md": "a17c6ffbcde6071a", + "skills/gsd-ns-review/skills/secure-phase/SKILL.md": "a6adf4729b606d5a", + "skills/gsd-ns-review/skills/ui-phase/SKILL.md": "7c9404102a9b9d74", + "skills/gsd-ns-review/skills/ui-review/SKILL.md": "ef8f643abff1486b", + "skills/gsd-ns-review/skills/validate-phase/SKILL.md": "70375e2381319d2b", + "skills/gsd-ns-workflow/SKILL.md": "46f5e3e89eed3743", + "skills/gsd-ns-workflow/skills/add-tests/SKILL.md": "6661195a66f25ae8", + "skills/gsd-ns-workflow/skills/ai-integration-phase/SKILL.md": "96c8151779ad510e", + "skills/gsd-ns-workflow/skills/autonomous/SKILL.md": "fb57d2c08ea9e8bc", + "skills/gsd-ns-workflow/skills/discuss-phase/SKILL.md": "ff522155265107d2", + "skills/gsd-ns-workflow/skills/execute-phase/SKILL.md": "13518a22e020e1bf", + "skills/gsd-ns-workflow/skills/fast/SKILL.md": "5f3f8b0c3f564d14", + "skills/gsd-ns-workflow/skills/mvp-phase/SKILL.md": "f9a1348c6c297579", + "skills/gsd-ns-workflow/skills/phase/SKILL.md": "1ed640e5f06c7be6", + "skills/gsd-ns-workflow/skills/plan-phase/SKILL.md": "75979bb1db5557af", + "skills/gsd-ns-workflow/skills/plan-review-convergence/SKILL.md": "c3dd8bfa877eaed5", + "skills/gsd-ns-workflow/skills/progress/SKILL.md": "943538c4ac6bde19", + "skills/gsd-ns-workflow/skills/quick/SKILL.md": "bd5e4cb79bc41611", + "skills/gsd-ns-workflow/skills/spec-phase/SKILL.md": "7e4dfa2070b7d9d1", + "skills/gsd-ns-workflow/skills/ultraplan-phase/SKILL.md": "2d78c7b16aabebce", + "skills/gsd-ns-workflow/skills/verify-work/SKILL.md": "4e7825f61f3cf69c" +} diff --git a/tests/fixtures/golden-install-parity/trae.json b/tests/fixtures/golden-install-parity/trae.json new file mode 100644 index 000000000..64550c5af --- /dev/null +++ b/tests/fixtures/golden-install-parity/trae.json @@ -0,0 +1,382 @@ +{ + ".gsd-profile": "0e716a5fef4e6dc1", + ".gsd/defaults.json": "560664b045e645cb", + "agents/gsd-advisor-researcher.md": "7dfdadd98ec1cccc", + "agents/gsd-ai-researcher.md": "0c7be272a6bf0ec6", + "agents/gsd-assumptions-analyzer.md": "dc5e9973ff0740d7", + "agents/gsd-code-fixer.md": "b64e97f0427daa68", + "agents/gsd-code-reviewer.md": "1a7ab7a2b76b6227", + "agents/gsd-codebase-mapper.md": "9bee4506eb5ff300", + "agents/gsd-debug-session-manager.md": "410e3739329bd9ac", + "agents/gsd-debugger.md": "4d6ce9b022df2a89", + "agents/gsd-doc-classifier.md": "5807f1f7cfa40c42", + "agents/gsd-doc-synthesizer.md": "0897098024c17ccd", + "agents/gsd-doc-verifier.md": "e51e50892963ccc7", + "agents/gsd-doc-writer.md": "a7367a8f99991382", + "agents/gsd-domain-researcher.md": "b80f76874c04e515", + "agents/gsd-eval-auditor.md": "220e0002679f7aa2", + "agents/gsd-eval-planner.md": "22334fde85723c9d", + "agents/gsd-executor.md": "63ab2c73cc093f60", + "agents/gsd-framework-selector.md": "7726fccc86bfeb50", + "agents/gsd-integration-checker.md": "7c3dbd934a2a189b", + "agents/gsd-intel-updater.md": "2751bbd33912aa1b", + "agents/gsd-mempalace-curator.md": "63676937f8970bf3", + "agents/gsd-nyquist-auditor.md": "5fb6cc4fef7ef276", + "agents/gsd-pattern-mapper.md": "b5d7a4abb1baecb9", + "agents/gsd-phase-researcher.md": "6d71f5c878fd1107", + "agents/gsd-plan-checker.md": "e2f092bafa6f2646", + "agents/gsd-planner.md": "e5913ce6c4887d8d", + "agents/gsd-project-researcher.md": "729378aedf68e404", + "agents/gsd-research-synthesizer.md": "27e70b07a4502b86", + "agents/gsd-roadmapper.md": "867a8ea6a533dce1", + "agents/gsd-security-auditor.md": "8303af19808131fb", + "agents/gsd-ui-auditor.md": "1513c76befcf221f", + "agents/gsd-ui-checker.md": "843c1deeaa1cb5e7", + "agents/gsd-ui-researcher.md": "e09f0f7034eaa366", + "agents/gsd-user-profiler.md": "622220df0654b6bf", + "agents/gsd-verifier.md": "1e8a6492303366a0", + "gsd-core/CHANGELOG.md": "e141e3fb369ff712", + "gsd-core/VERSION": "562368b20a64be95", + "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", + "gsd-core/bin/gsd-tools.cjs": "46deb2174be356dd", + "gsd-core/bin/gsd_run": "62d9b647ede212e6", + "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", + "gsd-core/bin/shared/config-schema.manifest.json": "67e4addbfd248a7c", + "gsd-core/bin/shared/model-catalog.json": "dbe26e683236d8c6", + "gsd-core/bin/shared/runtime-aliases.manifest.json": "f6c8b3af10dcfdc4", + "gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904", + "gsd-core/contexts/dev.md": "dcb0de9dce33cf41", + "gsd-core/contexts/research.md": "b3285d8e7209cc3b", + "gsd-core/contexts/review.md": "dc578fdd74bbea11", + "gsd-core/references/agent-contracts.md": "ff65e633c656c0d2", + "gsd-core/references/ai-evals.md": "b5afa786b938671e", + "gsd-core/references/ai-frameworks.md": "f827de93dde124eb", + "gsd-core/references/artifact-types.md": "8bd01fd75a2ba70e", + "gsd-core/references/autonomous-smart-discuss.md": "2fc710cde0ec7785", + "gsd-core/references/checkpoints.md": "4b8c645ffa695067", + "gsd-core/references/common-bug-patterns.md": "780145be56352626", + "gsd-core/references/context-budget.md": "b7e9640063775c98", + "gsd-core/references/continuation-format.md": "580287399ad3ba68", + "gsd-core/references/debugger-philosophy.md": "0466f95a3d6bfcd3", + "gsd-core/references/decimal-phase-calculation.md": "46b5ba045852c474", + "gsd-core/references/doc-conflict-engine.md": "883d0a1b9d9ff96e", + "gsd-core/references/domain-probes.md": "762b965e84035b72", + "gsd-core/references/edge-probe-fixtures/01-round-half-even/expected-coverage.json": "72d1e29cedc854ec", + "gsd-core/references/edge-probe-fixtures/01-round-half-even/requirements.json": "fbc1b355d8625eeb", + "gsd-core/references/edge-probe-fixtures/02-merge-intervals/expected-coverage.json": "fad67dcc8294f6da", + "gsd-core/references/edge-probe-fixtures/02-merge-intervals/requirements.json": "30a78ee9ce3473ea", + "gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/expected-coverage.json": "66dd60957fee45f0", + "gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/requirements.json": "47fca61f076835fa", + "gsd-core/references/edge-probe-fixtures/04-money-rounding/expected-coverage.json": "72d1e29cedc854ec", + "gsd-core/references/edge-probe-fixtures/04-money-rounding/requirements.json": "80f04f5c04fb24cf", + "gsd-core/references/edge-probe-fixtures/05-list-dedupe/expected-coverage.json": "fad67dcc8294f6da", + "gsd-core/references/edge-probe-fixtures/05-list-dedupe/requirements.json": "d38147adb0e5b342", + "gsd-core/references/edge-probe-fixtures/06-resolved-mixed/expected-coverage.json": "bc552c01939bf4f8", + "gsd-core/references/edge-probe-fixtures/06-resolved-mixed/requirements.json": "30a78ee9ce3473ea", + "gsd-core/references/edge-probe-fixtures/06-resolved-mixed/resolutions.json": "688ec62c13e08afe", + "gsd-core/references/edge-probe.md": "5687eba25a078561", + "gsd-core/references/execute-mvp-tdd.md": "a98a270a7ab126bc", + "gsd-core/references/execute-phase-between-wave-reset.md": "7a1aa0ad7963f809", + "gsd-core/references/execute-phase-context-guard.md": "a5a1058d35806a8e", + "gsd-core/references/execute-phase-wave-guard.md": "973a3a6a5db7e449", + "gsd-core/references/executor-examples.md": "ba59243ed45c8ab1", + "gsd-core/references/few-shot-examples/plan-checker.md": "2574808188ac9de4", + "gsd-core/references/few-shot-examples/verifier.md": "5badee4560b14ae8", + "gsd-core/references/gate-prompts.md": "099c8d52e3562336", + "gsd-core/references/gates.md": "7dc9fd3a3d6217c6", + "gsd-core/references/git-integration.md": "1bd40c3f94d712b1", + "gsd-core/references/git-planning-commit.md": "f897a15ebfc3f5a7", + "gsd-core/references/ios-scaffold.md": "5ef0cb7e0fac891f", + "gsd-core/references/loop-hook-dispatch.md": "32e5dfb4dba76987", + "gsd-core/references/mandatory-initial-read.md": "fe59abce693717cf", + "gsd-core/references/model-profile-resolution.md": "f32bb05102839767", + "gsd-core/references/model-profiles.md": "88c4308630f27f48", + "gsd-core/references/mvp-concepts.md": "3464783eaaef5c10", + "gsd-core/references/phase-argument-parsing.md": "e562947d1bf5c5a4", + "gsd-core/references/planner-antipatterns.md": "7ed54ec1e2cc54ac", + "gsd-core/references/planner-chunked.md": "79fe674221e738e6", + "gsd-core/references/planner-gap-closure.md": "76bee257911413e7", + "gsd-core/references/planner-graphify-auto-update.md": "1ed614dfba72f2a3", + "gsd-core/references/planner-guidance.md": "140b1266eeb096cb", + "gsd-core/references/planner-human-verify-mode.md": "0bb1176995ac1d81", + "gsd-core/references/planner-interface-context.md": "b28fa3da6ae739a8", + "gsd-core/references/planner-load-graph-context.md": "9a9383599893ea7e", + "gsd-core/references/planner-mvp-mode.md": "ec33050db81101a8", + "gsd-core/references/planner-reviews.md": "da39eace09a10743", + "gsd-core/references/planner-revision.md": "86ba8a511f081f05", + "gsd-core/references/planner-source-audit.md": "7de5bdb07232ce0b", + "gsd-core/references/planning-config.md": "970c47f816acdb30", + "gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json": "f10df472f2846cc6", + "gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json": "31e8a781eeffe020", + "gsd-core/references/prohibition-probe-fixtures/03-multi-prohibition/expected.json": "70a532a7cc1b6ae8", + "gsd-core/references/prohibition-probe.md": "52872d5393ea9daf", + "gsd-core/references/project-skills-discovery.md": "2aa2a5388d41a97e", + "gsd-core/references/questioning.md": "a8c988cab05f4651", + "gsd-core/references/research-documentation-lookup.md": "c070007d1d72ab71", + "gsd-core/references/research-philosophy.md": "62930e66cc979c1a", + "gsd-core/references/research-verification-protocol.md": "9c38c9d9a687e679", + "gsd-core/references/revision-loop.md": "e55ff32dd98c63df", + "gsd-core/references/scout-codebase.md": "ba266ecc18fbf172", + "gsd-core/references/security-asvs-levels.md": "4774fac3b94b6ca8", + "gsd-core/references/skeleton-template.md": "528691d1f0efa878", + "gsd-core/references/sketch-interactivity.md": "7d982fe877e1e1cc", + "gsd-core/references/sketch-theme-system.md": "33e2e96e450456f8", + "gsd-core/references/sketch-tooling.md": "df6c4f24c1c27611", + "gsd-core/references/sketch-variant-patterns.md": "66c197aa4fb52810", + "gsd-core/references/spidr-splitting.md": "074ac154c0e4f906", + "gsd-core/references/tdd.md": "e4708ede157478b6", + "gsd-core/references/thinking-models-debug.md": "2da61022b16c4e7c", + "gsd-core/references/thinking-models-execution.md": "dcc650a8b5f3e049", + "gsd-core/references/thinking-models-planning.md": "7e19462313fa028f", + "gsd-core/references/thinking-models-research.md": "5f6bf3f3b889c6e4", + "gsd-core/references/thinking-models-verification.md": "a71a933d51ca3d8d", + "gsd-core/references/thinking-partner.md": "827c1badf3e6df41", + "gsd-core/references/ui-brand.md": "48717bcfcd63bd27", + "gsd-core/references/universal-anti-patterns.md": "6a1245050b21df01", + "gsd-core/references/untrusted-input-boundary.md": "d33b80d4d348599a", + "gsd-core/references/user-profiling.md": "b50416fe57c1b321", + "gsd-core/references/user-story-template.md": "0cc50e06a144ff8a", + "gsd-core/references/verification-overrides.md": "a3e2d5166d16a37b", + "gsd-core/references/verification-patterns.md": "72f4d4814fdae9a8", + "gsd-core/references/verify-mvp-mode.md": "534bdc7f2432903a", + "gsd-core/references/workstream-flag.md": "77d0e004039fb480", + "gsd-core/references/worktree-branch-check.md": "8de8075d76ff71cd", + "gsd-core/references/worktree-path-safety.md": "34689482908229bc", + "gsd-core/templates/AI-SPEC.md": "24df5fe5ba34e367", + "gsd-core/templates/DEBUG.md": "57bd61bfd1d98e7e", + "gsd-core/templates/README.md": "332f65072c3f03ae", + "gsd-core/templates/SECURITY.md": "b628f7f1c6d2328f", + "gsd-core/templates/UAT.md": "68d32d1fea14e184", + "gsd-core/templates/UI-SPEC.md": "20ca56a4e3e21f01", + "gsd-core/templates/VALIDATION.md": "6144951011cdca57", + "gsd-core/templates/claude-md.md": "17217a07ab8a6485", + "gsd-core/templates/codebase/architecture.md": "6be88214162fdd89", + "gsd-core/templates/codebase/concerns.md": "efa26d1fb5132f25", + "gsd-core/templates/codebase/conventions.md": "c2e07698dad6b364", + "gsd-core/templates/codebase/integrations.md": "39bd23c71eedd564", + "gsd-core/templates/codebase/stack.md": "116e7e67dd87ddec", + "gsd-core/templates/codebase/structure.md": "c9c5df7a8911bca9", + "gsd-core/templates/codebase/testing.md": "76abff7f2050c9ea", + "gsd-core/templates/config.json": "1473dff11272d3c4", + "gsd-core/templates/context.md": "69b01e7909ea3f66", + "gsd-core/templates/continue-here.md": "f522a51b6895fba8", + "gsd-core/templates/copilot-instructions.md": "aea34bc52ff548ea", + "gsd-core/templates/debug-subagent-prompt.md": "8c18a89e25929d8e", + "gsd-core/templates/dev-preferences.md": "95048a71063d980b", + "gsd-core/templates/discovery.md": "e4ab738326eb70e0", + "gsd-core/templates/discussion-log.md": "cac1b48ec0f4dcb8", + "gsd-core/templates/milestone-archive.md": "591b6decdc0c0e51", + "gsd-core/templates/milestone.md": "74d2f750ae9f4a9c", + "gsd-core/templates/phase-prompt.md": "213ccd947451ff2b", + "gsd-core/templates/planner-subagent-prompt.md": "6c9f1b23ee3dc05f", + "gsd-core/templates/project.md": "1f687c7a88381080", + "gsd-core/templates/requirements.md": "a44de4c2f146e473", + "gsd-core/templates/research-project/ARCHITECTURE.md": "746b9ef791d758b0", + "gsd-core/templates/research-project/FEATURES.md": "f2b800de5df91b0f", + "gsd-core/templates/research-project/PITFALLS.md": "3ef75fa768422eec", + "gsd-core/templates/research-project/STACK.md": "82c85799ac4dd344", + "gsd-core/templates/research-project/SUMMARY.md": "dceb2f346388839d", + "gsd-core/templates/research.md": "fa6dfb2ff2e8d273", + "gsd-core/templates/retrospective.md": "03981e30dd760103", + "gsd-core/templates/roadmap.md": "e4e35a9eb5dd4d4f", + "gsd-core/templates/spec.md": "7dc900c355098d8b", + "gsd-core/templates/state.md": "73e424b8c70b765c", + "gsd-core/templates/summary-complex.md": "a5e40574fd8894dc", + "gsd-core/templates/summary-minimal.md": "7d09b5e709e2e67c", + "gsd-core/templates/summary-standard.md": "e8d9cf4a8377cdff", + "gsd-core/templates/summary.md": "23c40f6503b3ea98", + "gsd-core/templates/user-profile.md": "20749f23e4c413fc", + "gsd-core/templates/user-setup.md": "78b7d718b6e8d67c", + "gsd-core/templates/verification-report.md": "dd5faa6254183731", + "gsd-core/workflows/_runtime-launcher.snippet.sh": "c0994e00f976dbfb", + "gsd-core/workflows/add-backlog.md": "36f58e864aeaf24d", + "gsd-core/workflows/add-phase.md": "4e498058a786615f", + "gsd-core/workflows/add-tests.md": "688a232a0a4b918e", + "gsd-core/workflows/add-todo.md": "792592b2133698a7", + "gsd-core/workflows/ai-integration-phase.md": "0672cc9afb7b7a0b", + "gsd-core/workflows/analyze-dependencies.md": "77aff48f97fa6f1c", + "gsd-core/workflows/audit-fix.md": "493e4abe4f701662", + "gsd-core/workflows/audit-milestone.md": "26b2428d9a8df7b2", + "gsd-core/workflows/audit-uat.md": "f02cd45df6304b06", + "gsd-core/workflows/autonomous.md": "09f390b492bc8801", + "gsd-core/workflows/check-todos.md": "e4209530a4132312", + "gsd-core/workflows/cleanup.md": "78080ff25ba43f8c", + "gsd-core/workflows/code-review-fix.md": "4eebfb7f1612b441", + "gsd-core/workflows/code-review.md": "7630639b5f2f9ff2", + "gsd-core/workflows/complete-milestone.md": "0bbdd56c4251797d", + "gsd-core/workflows/debug.md": "87c20def8d313e45", + "gsd-core/workflows/diagnose-issues.md": "a0b3fee49d516c0b", + "gsd-core/workflows/discovery-phase.md": "b32b6197b66c9a13", + "gsd-core/workflows/discuss-phase-assumptions.md": "3689bacffdd7cdd4", + "gsd-core/workflows/discuss-phase-power.md": "290c0d83d783f9f6", + "gsd-core/workflows/discuss-phase.md": "9c4af7ae57aaad8f", + "gsd-core/workflows/discuss-phase/modes/advisor.md": "5522bd33cbd9b9b8", + "gsd-core/workflows/discuss-phase/modes/all.md": "e698b84ebb2ff56d", + "gsd-core/workflows/discuss-phase/modes/analyze.md": "c824ef897199b730", + "gsd-core/workflows/discuss-phase/modes/auto.md": "bb489751893f4498", + "gsd-core/workflows/discuss-phase/modes/batch.md": "21ee55869eaa7ece", + "gsd-core/workflows/discuss-phase/modes/chain.md": "c643b0b42b24e0d4", + "gsd-core/workflows/discuss-phase/modes/default.md": "67d1b67f61f03966", + "gsd-core/workflows/discuss-phase/modes/power.md": "55b61b7dc83a58b2", + "gsd-core/workflows/discuss-phase/modes/text.md": "e1043e27b50a5e18", + "gsd-core/workflows/discuss-phase/templates/checkpoint.json": "e3bc3dca49db59eb", + "gsd-core/workflows/discuss-phase/templates/context.md": "6cd929e989fe2b0f", + "gsd-core/workflows/discuss-phase/templates/discussion-log.md": "1bbd7703f11128e1", + "gsd-core/workflows/do.md": "461d545bbde0dc8f", + "gsd-core/workflows/docs-update.md": "dd1050b32c2dc170", + "gsd-core/workflows/edit-phase.md": "38f9c9945073ac8c", + "gsd-core/workflows/eval-review.md": "48fdef1cf0e67525", + "gsd-core/workflows/execute-phase.md": "65f0e997673291b6", + "gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md": "44d46d3e98942efc", + "gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md": "7ebb7d1af6082028", + "gsd-core/workflows/execute-phase/steps/post-merge-gate.md": "a2fc97089560ec0a", + "gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md": "be84efbd71e1513e", + "gsd-core/workflows/execute-plan.md": "6d437a23e6d9ffcc", + "gsd-core/workflows/explore.md": "9f24bf678b1ef341", + "gsd-core/workflows/extract-learnings.md": "0e0eead67cec3d28", + "gsd-core/workflows/fast.md": "0162075e44072447", + "gsd-core/workflows/forensics.md": "4b38a662876628a2", + "gsd-core/workflows/graduation.md": "63b74175d5e9cc1e", + "gsd-core/workflows/health.md": "9ddf28b84e1ce089", + "gsd-core/workflows/help.md": "08e1349950c5602a", + "gsd-core/workflows/help/modes/brief.md": "2e923b3ed635d495", + "gsd-core/workflows/help/modes/default.md": "1b3c315342701265", + "gsd-core/workflows/help/modes/full.md": "ffb3e525818ed5bc", + "gsd-core/workflows/help/modes/topic.md": "5419498effee148f", + "gsd-core/workflows/import.md": "47cc99b635e45f82", + "gsd-core/workflows/inbox.md": "e9ea37b2d46dc5b4", + "gsd-core/workflows/ingest-docs.md": "9554d457d64b7c17", + "gsd-core/workflows/insert-phase.md": "ff204ee159b41506", + "gsd-core/workflows/list-phase-assumptions.md": "eb986c301d59f620", + "gsd-core/workflows/list-seeds.md": "fa287a9ed912eb17", + "gsd-core/workflows/list-workspaces.md": "4668ebdc3380a7a6", + "gsd-core/workflows/manager.md": "79a6e4de6654f4e7", + "gsd-core/workflows/map-codebase.md": "f90d0577d254c38e", + "gsd-core/workflows/milestone-summary.md": "b50a5d4369d29978", + "gsd-core/workflows/mvp-phase.md": "1d2292f3c444c13d", + "gsd-core/workflows/new-milestone.md": "28d4d2b571cae752", + "gsd-core/workflows/new-project.md": "d1a0070ed292c25a", + "gsd-core/workflows/new-workspace.md": "555689ccf58bafdb", + "gsd-core/workflows/next.md": "569c68513fd94ea7", + "gsd-core/workflows/node-repair.md": "07a1628e5a1ff96b", + "gsd-core/workflows/note.md": "acc9130fb1e94f0b", + "gsd-core/workflows/pause-work.md": "09a6b8980f7b771a", + "gsd-core/workflows/plan-milestone-gaps.md": "022822b3b6971b75", + "gsd-core/workflows/plan-phase.md": "c3e494c63f5c04c5", + "gsd-core/workflows/plan-review-convergence.md": "8fb889570c17db15", + "gsd-core/workflows/plant-seed.md": "dc911406ada4d188", + "gsd-core/workflows/pr-branch.md": "e939128047e02395", + "gsd-core/workflows/profile-user.md": "0831c4b96b1265c9", + "gsd-core/workflows/progress.md": "3e2b44a7a175654c", + "gsd-core/workflows/quick.md": "42b6f01afa2fc359", + "gsd-core/workflows/reapply-patches.md": "ea66e63f56e7deb3", + "gsd-core/workflows/remove-phase.md": "5a2521695edd486b", + "gsd-core/workflows/remove-workspace.md": "ed8e5e30c33f1bfd", + "gsd-core/workflows/resume-project.md": "ee9dcc4e3dd3e32c", + "gsd-core/workflows/review.md": "402fcc0b4b6a2794", + "gsd-core/workflows/scan.md": "afc48f3339293b30", + "gsd-core/workflows/secure-phase.md": "882886f04d3b7e82", + "gsd-core/workflows/session-report.md": "2e5b1205324ddefa", + "gsd-core/workflows/settings-advanced.md": "724b30d7abcd168c", + "gsd-core/workflows/settings-integrations.md": "9aa7005e6dd84bcc", + "gsd-core/workflows/settings.md": "4332d1ee0efe797b", + "gsd-core/workflows/ship.md": "0647d19b5487b1a3", + "gsd-core/workflows/sketch-wrap-up.md": "4894dac77fc42655", + "gsd-core/workflows/sketch.md": "52f4a04f511e205e", + "gsd-core/workflows/spec-phase.md": "56cecc44b2cc0bc4", + "gsd-core/workflows/spike-wrap-up.md": "1f57905138a648ac", + "gsd-core/workflows/spike.md": "48df8b626cbd378d", + "gsd-core/workflows/stats.md": "18089135bc41f1b4", + "gsd-core/workflows/sync-skills.md": "b505e6f8331c0918", + "gsd-core/workflows/thread.md": "9fe3ec12e491bec3", + "gsd-core/workflows/transition.md": "b49ddd9247f804e7", + "gsd-core/workflows/ui-phase.md": "a725ebdd4f47fb97", + "gsd-core/workflows/ui-review.md": "b3221cac976daffa", + "gsd-core/workflows/ultraplan-phase.md": "06ac6fbb061b2464", + "gsd-core/workflows/undo.md": "59b8baa54efc4110", + "gsd-core/workflows/update.md": "e259898f86ae7133", + "gsd-core/workflows/validate-phase.md": "70d102b4d5113dd4", + "gsd-core/workflows/verify-phase.md": "ba797ce64e593a10", + "gsd-core/workflows/verify-work.md": "1e2a10168f8fdc2b", + "scripts/changeset/README.md": "86ff89331dfd94b2", + "scripts/changeset/cli.cjs": "68f92a344b199271", + "scripts/changeset/github-release-notes.cjs": "795677f0c009b132", + "scripts/changeset/lint.cjs": "0066faed159154f0", + "scripts/changeset/new.cjs": "4991e21fd17f5541", + "scripts/changeset/parse.cjs": "f9a949cbcab56445", + "scripts/changeset/render.cjs": "e47bc3e1587c3cae", + "scripts/changeset/serialize.cjs": "ac0b8fe6f87cdb0e", + "scripts/fix-slash-commands.cjs": "0519742531ff3529", + "scripts/lib/allowlist-ratchet.cjs": "ffaceaac3efc2660", + "scripts/lib/cli-exit.cjs": "612d0c372c75b7e7", + "skills/gsd-ns-context/SKILL.md": "e278a50f3ecb8f56", + "skills/gsd-ns-context/skills/docs-update/SKILL.md": "52755343585b987b", + "skills/gsd-ns-context/skills/extract-learnings/SKILL.md": "d39269bea995fabc", + "skills/gsd-ns-context/skills/graphify/SKILL.md": "e0253aef14693a7e", + "skills/gsd-ns-context/skills/map-codebase/SKILL.md": "0910348701b1fc65", + "skills/gsd-ns-context/skills/mempalace-capture/SKILL.md": "92740c97e0ce6942", + "skills/gsd-ns-context/skills/mempalace-recall/SKILL.md": "d41523e659d1920f", + "skills/gsd-ns-ideate/SKILL.md": "c10342345c01c91f", + "skills/gsd-ns-ideate/skills/capture/SKILL.md": "90aee26970fa7639", + "skills/gsd-ns-ideate/skills/explore/SKILL.md": "d8e26a5ed95a4ecc", + "skills/gsd-ns-ideate/skills/sketch/SKILL.md": "ede2d3ab60a3c613", + "skills/gsd-ns-ideate/skills/spec-phase/SKILL.md": "f773e17e1ae7fb35", + "skills/gsd-ns-ideate/skills/spike/SKILL.md": "da215c1fb96a70e5", + "skills/gsd-ns-manage/SKILL.md": "0f4fb6d2f96ed7e9", + "skills/gsd-ns-manage/skills/cleanup/SKILL.md": "a578d6536b9cabc3", + "skills/gsd-ns-manage/skills/config/SKILL.md": "becac686746b68a4", + "skills/gsd-ns-manage/skills/health/SKILL.md": "74a68e1c1b310bef", + "skills/gsd-ns-manage/skills/help/SKILL.md": "0f0ae57a446b4601", + "skills/gsd-ns-manage/skills/inbox/SKILL.md": "34370c6138415209", + "skills/gsd-ns-manage/skills/manager/SKILL.md": "fba2aa10f8b29d84", + "skills/gsd-ns-manage/skills/pause-work/SKILL.md": "42924a70d6a4b96f", + "skills/gsd-ns-manage/skills/pr-branch/SKILL.md": "c25b5a3e31ab6f74", + "skills/gsd-ns-manage/skills/resume-work/SKILL.md": "5f0d4d0e86b99180", + "skills/gsd-ns-manage/skills/settings/SKILL.md": "67eff2d16e17403c", + "skills/gsd-ns-manage/skills/ship/SKILL.md": "4ad9695934e069ee", + "skills/gsd-ns-manage/skills/stats/SKILL.md": "25898070fb2a4b20", + "skills/gsd-ns-manage/skills/surface/SKILL.md": "c30399ea5d11cc5d", + "skills/gsd-ns-manage/skills/thread/SKILL.md": "67b5a0451b58c50c", + "skills/gsd-ns-manage/skills/undo/SKILL.md": "2a37b9c270d4c785", + "skills/gsd-ns-manage/skills/update/SKILL.md": "2fc47bf059aa7b38", + "skills/gsd-ns-manage/skills/workspace/SKILL.md": "257d17b9d20274d8", + "skills/gsd-ns-manage/skills/workstreams/SKILL.md": "737841783c7fdd4f", + "skills/gsd-ns-project/SKILL.md": "5668e3a5e1d8896d", + "skills/gsd-ns-project/skills/audit-milestone/SKILL.md": "0ccabc5556d65d56", + "skills/gsd-ns-project/skills/complete-milestone/SKILL.md": "a69d534b385d6aaf", + "skills/gsd-ns-project/skills/import/SKILL.md": "a11e630bfd8ae4de", + "skills/gsd-ns-project/skills/ingest-docs/SKILL.md": "d26637cdafd1a629", + "skills/gsd-ns-project/skills/milestone-summary/SKILL.md": "9d408f56712ba948", + "skills/gsd-ns-project/skills/new-milestone/SKILL.md": "7cbc011130e8590c", + "skills/gsd-ns-project/skills/new-project/SKILL.md": "ff0d57491df30624", + "skills/gsd-ns-project/skills/profile-user/SKILL.md": "4f526120cda2f6cc", + "skills/gsd-ns-project/skills/review-backlog/SKILL.md": "d8a150558cc9326a", + "skills/gsd-ns-review/SKILL.md": "c5afe33c212947dd", + "skills/gsd-ns-review/skills/audit-fix/SKILL.md": "2c1f601f2fb52585", + "skills/gsd-ns-review/skills/audit-uat/SKILL.md": "2c450432b6fd1c3e", + "skills/gsd-ns-review/skills/code-review/SKILL.md": "71f717d78fd4954d", + "skills/gsd-ns-review/skills/debug/SKILL.md": "9d814fba870c538e", + "skills/gsd-ns-review/skills/eval-review/SKILL.md": "d42b504c0ca2dc69", + "skills/gsd-ns-review/skills/forensics/SKILL.md": "250f476c4c547a0f", + "skills/gsd-ns-review/skills/review/SKILL.md": "ace2ad1db5c625c6", + "skills/gsd-ns-review/skills/secure-phase/SKILL.md": "b35ac9da51635368", + "skills/gsd-ns-review/skills/ui-phase/SKILL.md": "89a29a2fa62b177e", + "skills/gsd-ns-review/skills/ui-review/SKILL.md": "3e23efe03dafd691", + "skills/gsd-ns-review/skills/validate-phase/SKILL.md": "fa1ab5c9a869f6cd", + "skills/gsd-ns-workflow/SKILL.md": "7dbd699b5130c0d5", + "skills/gsd-ns-workflow/skills/add-tests/SKILL.md": "6d64ae85b590274f", + "skills/gsd-ns-workflow/skills/ai-integration-phase/SKILL.md": "c458b792bdf5b1f2", + "skills/gsd-ns-workflow/skills/autonomous/SKILL.md": "37dc7b78ceb74803", + "skills/gsd-ns-workflow/skills/discuss-phase/SKILL.md": "c41cf9bf50dc6be6", + "skills/gsd-ns-workflow/skills/execute-phase/SKILL.md": "eb0158de64f22a55", + "skills/gsd-ns-workflow/skills/fast/SKILL.md": "3eae6536d09c2532", + "skills/gsd-ns-workflow/skills/mvp-phase/SKILL.md": "81c98f4436888cb8", + "skills/gsd-ns-workflow/skills/phase/SKILL.md": "50f6cc4df79a26b7", + "skills/gsd-ns-workflow/skills/plan-phase/SKILL.md": "44d42fa41a094ad9", + "skills/gsd-ns-workflow/skills/plan-review-convergence/SKILL.md": "fee26bc1daedbff5", + "skills/gsd-ns-workflow/skills/progress/SKILL.md": "de48301875978833", + "skills/gsd-ns-workflow/skills/quick/SKILL.md": "d81670625c884d2c", + "skills/gsd-ns-workflow/skills/spec-phase/SKILL.md": "f773e17e1ae7fb35", + "skills/gsd-ns-workflow/skills/ultraplan-phase/SKILL.md": "1cc863a090cdcc6b", + "skills/gsd-ns-workflow/skills/verify-work/SKILL.md": "aae57a48a1b966ac" +} diff --git a/tests/fixtures/golden-install-parity/windsurf.json b/tests/fixtures/golden-install-parity/windsurf.json new file mode 100644 index 000000000..6e9aa681e --- /dev/null +++ b/tests/fixtures/golden-install-parity/windsurf.json @@ -0,0 +1,312 @@ +{ + ".gsd-profile": "0e716a5fef4e6dc1", + ".gsd/defaults.json": "560664b045e645cb", + "agents/gsd-advisor-researcher.md": "7dfdadd98ec1cccc", + "agents/gsd-ai-researcher.md": "0c7be272a6bf0ec6", + "agents/gsd-assumptions-analyzer.md": "dc5e9973ff0740d7", + "agents/gsd-code-fixer.md": "8a0a2d602e2e553b", + "agents/gsd-code-reviewer.md": "b9eae8c588ca34fc", + "agents/gsd-codebase-mapper.md": "5212f81b9bdddf4e", + "agents/gsd-debug-session-manager.md": "40edd70065aa07bb", + "agents/gsd-debugger.md": "643fa461a0249194", + "agents/gsd-doc-classifier.md": "5807f1f7cfa40c42", + "agents/gsd-doc-synthesizer.md": "0897098024c17ccd", + "agents/gsd-doc-verifier.md": "ede2f297b73b5a86", + "agents/gsd-doc-writer.md": "6b5d454ed7ca8fcb", + "agents/gsd-domain-researcher.md": "56395dbdabf076f6", + "agents/gsd-eval-auditor.md": "7ae0c5e3ab6871f6", + "agents/gsd-eval-planner.md": "2049dac060d00eda", + "agents/gsd-executor.md": "b78eb2bdf8d83ab3", + "agents/gsd-framework-selector.md": "4b77eebbe9288d80", + "agents/gsd-integration-checker.md": "2ee5c85edce7842a", + "agents/gsd-intel-updater.md": "16ee6795da80a7c0", + "agents/gsd-mempalace-curator.md": "63676937f8970bf3", + "agents/gsd-nyquist-auditor.md": "8c02fb3e41caed29", + "agents/gsd-pattern-mapper.md": "ada0c169daa2f0ec", + "agents/gsd-phase-researcher.md": "43d0390c4468a8eb", + "agents/gsd-plan-checker.md": "9dc374ed91d8cdcf", + "agents/gsd-planner.md": "13e2861f9c325194", + "agents/gsd-project-researcher.md": "5c708d9ccacf40b6", + "agents/gsd-research-synthesizer.md": "7afe0b71bf76a91f", + "agents/gsd-roadmapper.md": "befd552019727d2d", + "agents/gsd-security-auditor.md": "092271a639d991a6", + "agents/gsd-ui-auditor.md": "e9b377d2a0f5185d", + "agents/gsd-ui-checker.md": "bd8e9be4c75dcdcf", + "agents/gsd-ui-researcher.md": "f04d24e6459bd23d", + "agents/gsd-user-profiler.md": "622220df0654b6bf", + "agents/gsd-verifier.md": "88eac9841f52dd8c", + "gsd-core/CHANGELOG.md": "e141e3fb369ff712", + "gsd-core/VERSION": "562368b20a64be95", + "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", + "gsd-core/bin/gsd-tools.cjs": "46deb2174be356dd", + "gsd-core/bin/gsd_run": "62d9b647ede212e6", + "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", + "gsd-core/bin/shared/config-schema.manifest.json": "67e4addbfd248a7c", + "gsd-core/bin/shared/model-catalog.json": "dbe26e683236d8c6", + "gsd-core/bin/shared/runtime-aliases.manifest.json": "f6c8b3af10dcfdc4", + "gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904", + "gsd-core/contexts/dev.md": "dcb0de9dce33cf41", + "gsd-core/contexts/research.md": "b3285d8e7209cc3b", + "gsd-core/contexts/review.md": "dc578fdd74bbea11", + "gsd-core/references/agent-contracts.md": "ff65e633c656c0d2", + "gsd-core/references/ai-evals.md": "b5afa786b938671e", + "gsd-core/references/ai-frameworks.md": "f827de93dde124eb", + "gsd-core/references/artifact-types.md": "8bd01fd75a2ba70e", + "gsd-core/references/autonomous-smart-discuss.md": "273b371c5751f35a", + "gsd-core/references/checkpoints.md": "808e4fcaa2fda15c", + "gsd-core/references/common-bug-patterns.md": "780145be56352626", + "gsd-core/references/context-budget.md": "ff843cd6139c8564", + "gsd-core/references/continuation-format.md": "580287399ad3ba68", + "gsd-core/references/debugger-philosophy.md": "0466f95a3d6bfcd3", + "gsd-core/references/decimal-phase-calculation.md": "46b5ba045852c474", + "gsd-core/references/doc-conflict-engine.md": "883d0a1b9d9ff96e", + "gsd-core/references/domain-probes.md": "762b965e84035b72", + "gsd-core/references/edge-probe-fixtures/01-round-half-even/expected-coverage.json": "72d1e29cedc854ec", + "gsd-core/references/edge-probe-fixtures/01-round-half-even/requirements.json": "fbc1b355d8625eeb", + "gsd-core/references/edge-probe-fixtures/02-merge-intervals/expected-coverage.json": "fad67dcc8294f6da", + "gsd-core/references/edge-probe-fixtures/02-merge-intervals/requirements.json": "30a78ee9ce3473ea", + "gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/expected-coverage.json": "66dd60957fee45f0", + "gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/requirements.json": "47fca61f076835fa", + "gsd-core/references/edge-probe-fixtures/04-money-rounding/expected-coverage.json": "72d1e29cedc854ec", + "gsd-core/references/edge-probe-fixtures/04-money-rounding/requirements.json": "80f04f5c04fb24cf", + "gsd-core/references/edge-probe-fixtures/05-list-dedupe/expected-coverage.json": "fad67dcc8294f6da", + "gsd-core/references/edge-probe-fixtures/05-list-dedupe/requirements.json": "d38147adb0e5b342", + "gsd-core/references/edge-probe-fixtures/06-resolved-mixed/expected-coverage.json": "bc552c01939bf4f8", + "gsd-core/references/edge-probe-fixtures/06-resolved-mixed/requirements.json": "30a78ee9ce3473ea", + "gsd-core/references/edge-probe-fixtures/06-resolved-mixed/resolutions.json": "688ec62c13e08afe", + "gsd-core/references/edge-probe.md": "692b44ca096f96e6", + "gsd-core/references/execute-mvp-tdd.md": "a98a270a7ab126bc", + "gsd-core/references/execute-phase-between-wave-reset.md": "1587345770b19c7f", + "gsd-core/references/execute-phase-context-guard.md": "a5a1058d35806a8e", + "gsd-core/references/execute-phase-wave-guard.md": "96485eccd4606e54", + "gsd-core/references/executor-examples.md": "ba59243ed45c8ab1", + "gsd-core/references/few-shot-examples/plan-checker.md": "2574808188ac9de4", + "gsd-core/references/few-shot-examples/verifier.md": "5badee4560b14ae8", + "gsd-core/references/gate-prompts.md": "099c8d52e3562336", + "gsd-core/references/gates.md": "7dc9fd3a3d6217c6", + "gsd-core/references/git-integration.md": "6ec36ea6b868655f", + "gsd-core/references/git-planning-commit.md": "f897a15ebfc3f5a7", + "gsd-core/references/ios-scaffold.md": "5ef0cb7e0fac891f", + "gsd-core/references/loop-hook-dispatch.md": "32e5dfb4dba76987", + "gsd-core/references/mandatory-initial-read.md": "fe59abce693717cf", + "gsd-core/references/model-profile-resolution.md": "f32bb05102839767", + "gsd-core/references/model-profiles.md": "7f812fafd1fe4b52", + "gsd-core/references/mvp-concepts.md": "3464783eaaef5c10", + "gsd-core/references/phase-argument-parsing.md": "e562947d1bf5c5a4", + "gsd-core/references/planner-antipatterns.md": "7ed54ec1e2cc54ac", + "gsd-core/references/planner-chunked.md": "79fe674221e738e6", + "gsd-core/references/planner-gap-closure.md": "76bee257911413e7", + "gsd-core/references/planner-graphify-auto-update.md": "1ed614dfba72f2a3", + "gsd-core/references/planner-guidance.md": "140b1266eeb096cb", + "gsd-core/references/planner-human-verify-mode.md": "86c8c7052806711f", + "gsd-core/references/planner-interface-context.md": "b28fa3da6ae739a8", + "gsd-core/references/planner-load-graph-context.md": "18d50b6d12db830e", + "gsd-core/references/planner-mvp-mode.md": "ec33050db81101a8", + "gsd-core/references/planner-reviews.md": "da39eace09a10743", + "gsd-core/references/planner-revision.md": "86ba8a511f081f05", + "gsd-core/references/planner-source-audit.md": "7de5bdb07232ce0b", + "gsd-core/references/planning-config.md": "742e11db47c5c0aa", + "gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json": "f10df472f2846cc6", + "gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json": "31e8a781eeffe020", + "gsd-core/references/prohibition-probe-fixtures/03-multi-prohibition/expected.json": "70a532a7cc1b6ae8", + "gsd-core/references/prohibition-probe.md": "8f3eb787e3845e2f", + "gsd-core/references/project-skills-discovery.md": "8a03bb1f0960e235", + "gsd-core/references/questioning.md": "faac32813d1735bd", + "gsd-core/references/research-documentation-lookup.md": "c070007d1d72ab71", + "gsd-core/references/research-philosophy.md": "62930e66cc979c1a", + "gsd-core/references/research-verification-protocol.md": "9c38c9d9a687e679", + "gsd-core/references/revision-loop.md": "e55ff32dd98c63df", + "gsd-core/references/scout-codebase.md": "ba266ecc18fbf172", + "gsd-core/references/security-asvs-levels.md": "4774fac3b94b6ca8", + "gsd-core/references/skeleton-template.md": "528691d1f0efa878", + "gsd-core/references/sketch-interactivity.md": "7d982fe877e1e1cc", + "gsd-core/references/sketch-theme-system.md": "33e2e96e450456f8", + "gsd-core/references/sketch-tooling.md": "df6c4f24c1c27611", + "gsd-core/references/sketch-variant-patterns.md": "66c197aa4fb52810", + "gsd-core/references/spidr-splitting.md": "074ac154c0e4f906", + "gsd-core/references/tdd.md": "e4708ede157478b6", + "gsd-core/references/thinking-models-debug.md": "2da61022b16c4e7c", + "gsd-core/references/thinking-models-execution.md": "dcc650a8b5f3e049", + "gsd-core/references/thinking-models-planning.md": "7e19462313fa028f", + "gsd-core/references/thinking-models-research.md": "5f6bf3f3b889c6e4", + "gsd-core/references/thinking-models-verification.md": "a71a933d51ca3d8d", + "gsd-core/references/thinking-partner.md": "827c1badf3e6df41", + "gsd-core/references/ui-brand.md": "48717bcfcd63bd27", + "gsd-core/references/universal-anti-patterns.md": "6a1245050b21df01", + "gsd-core/references/untrusted-input-boundary.md": "d33b80d4d348599a", + "gsd-core/references/user-profiling.md": "b50416fe57c1b321", + "gsd-core/references/user-story-template.md": "0cc50e06a144ff8a", + "gsd-core/references/verification-overrides.md": "a3e2d5166d16a37b", + "gsd-core/references/verification-patterns.md": "72f4d4814fdae9a8", + "gsd-core/references/verify-mvp-mode.md": "534bdc7f2432903a", + "gsd-core/references/workstream-flag.md": "481ec11fe3e22236", + "gsd-core/references/worktree-branch-check.md": "f6018dddf15bc1cb", + "gsd-core/references/worktree-path-safety.md": "0e8a26b9b3424974", + "gsd-core/templates/AI-SPEC.md": "24df5fe5ba34e367", + "gsd-core/templates/DEBUG.md": "57bd61bfd1d98e7e", + "gsd-core/templates/README.md": "ee35799bf08677f3", + "gsd-core/templates/SECURITY.md": "b628f7f1c6d2328f", + "gsd-core/templates/UAT.md": "68d32d1fea14e184", + "gsd-core/templates/UI-SPEC.md": "20ca56a4e3e21f01", + "gsd-core/templates/VALIDATION.md": "6144951011cdca57", + "gsd-core/templates/claude-md.md": "20be2a0201ab92cd", + "gsd-core/templates/codebase/architecture.md": "6be88214162fdd89", + "gsd-core/templates/codebase/concerns.md": "efa26d1fb5132f25", + "gsd-core/templates/codebase/conventions.md": "c2e07698dad6b364", + "gsd-core/templates/codebase/integrations.md": "39bd23c71eedd564", + "gsd-core/templates/codebase/stack.md": "116e7e67dd87ddec", + "gsd-core/templates/codebase/structure.md": "f7e0345b72e84e6e", + "gsd-core/templates/codebase/testing.md": "76abff7f2050c9ea", + "gsd-core/templates/config.json": "1473dff11272d3c4", + "gsd-core/templates/context.md": "69b01e7909ea3f66", + "gsd-core/templates/continue-here.md": "f522a51b6895fba8", + "gsd-core/templates/copilot-instructions.md": "aea34bc52ff548ea", + "gsd-core/templates/debug-subagent-prompt.md": "8c18a89e25929d8e", + "gsd-core/templates/dev-preferences.md": "95048a71063d980b", + "gsd-core/templates/discovery.md": "e4ab738326eb70e0", + "gsd-core/templates/discussion-log.md": "cac1b48ec0f4dcb8", + "gsd-core/templates/milestone-archive.md": "591b6decdc0c0e51", + "gsd-core/templates/milestone.md": "74d2f750ae9f4a9c", + "gsd-core/templates/phase-prompt.md": "213ccd947451ff2b", + "gsd-core/templates/planner-subagent-prompt.md": "6c9f1b23ee3dc05f", + "gsd-core/templates/project.md": "1f687c7a88381080", + "gsd-core/templates/requirements.md": "a44de4c2f146e473", + "gsd-core/templates/research-project/ARCHITECTURE.md": "746b9ef791d758b0", + "gsd-core/templates/research-project/FEATURES.md": "f2b800de5df91b0f", + "gsd-core/templates/research-project/PITFALLS.md": "3ef75fa768422eec", + "gsd-core/templates/research-project/STACK.md": "82c85799ac4dd344", + "gsd-core/templates/research-project/SUMMARY.md": "dceb2f346388839d", + "gsd-core/templates/research.md": "fa6dfb2ff2e8d273", + "gsd-core/templates/retrospective.md": "03981e30dd760103", + "gsd-core/templates/roadmap.md": "e4e35a9eb5dd4d4f", + "gsd-core/templates/spec.md": "7dc900c355098d8b", + "gsd-core/templates/state.md": "73e424b8c70b765c", + "gsd-core/templates/summary-complex.md": "a5e40574fd8894dc", + "gsd-core/templates/summary-minimal.md": "7d09b5e709e2e67c", + "gsd-core/templates/summary-standard.md": "e8d9cf4a8377cdff", + "gsd-core/templates/summary.md": "23c40f6503b3ea98", + "gsd-core/templates/user-profile.md": "20749f23e4c413fc", + "gsd-core/templates/user-setup.md": "78b7d718b6e8d67c", + "gsd-core/templates/verification-report.md": "dd5faa6254183731", + "gsd-core/workflows/_runtime-launcher.snippet.sh": "c0994e00f976dbfb", + "gsd-core/workflows/add-backlog.md": "b84c744efaed7a2e", + "gsd-core/workflows/add-phase.md": "199ac1b4879fb51a", + "gsd-core/workflows/add-tests.md": "5aafbe6d624d41c9", + "gsd-core/workflows/add-todo.md": "7151303ccc3e6d05", + "gsd-core/workflows/ai-integration-phase.md": "9b287efec21a3c0b", + "gsd-core/workflows/analyze-dependencies.md": "77aff48f97fa6f1c", + "gsd-core/workflows/audit-fix.md": "e1ad3e58979d9eef", + "gsd-core/workflows/audit-milestone.md": "e578d1ce71b172e6", + "gsd-core/workflows/audit-uat.md": "0f779101b40c7229", + "gsd-core/workflows/autonomous.md": "fbd5185763707f74", + "gsd-core/workflows/check-todos.md": "ad2ff17a672e7101", + "gsd-core/workflows/cleanup.md": "edc0bc375acdb563", + "gsd-core/workflows/code-review-fix.md": "aa750f6d1abd3b9f", + "gsd-core/workflows/code-review.md": "20c76ddd702e7cd5", + "gsd-core/workflows/complete-milestone.md": "6706e0dc3e95acd0", + "gsd-core/workflows/debug.md": "9679214d9472e213", + "gsd-core/workflows/diagnose-issues.md": "7c0b8debfc906ca0", + "gsd-core/workflows/discovery-phase.md": "7dcf150998559c11", + "gsd-core/workflows/discuss-phase-assumptions.md": "3f3b0ac79cdb5631", + "gsd-core/workflows/discuss-phase-power.md": "290c0d83d783f9f6", + "gsd-core/workflows/discuss-phase.md": "25d8a61ecf8c21a0", + "gsd-core/workflows/discuss-phase/modes/advisor.md": "1be08d135fd60027", + "gsd-core/workflows/discuss-phase/modes/all.md": "d2a1d16e2508a0cd", + "gsd-core/workflows/discuss-phase/modes/analyze.md": "c824ef897199b730", + "gsd-core/workflows/discuss-phase/modes/auto.md": "a66e476942667f9f", + "gsd-core/workflows/discuss-phase/modes/batch.md": "c23d5bdbdb60362c", + "gsd-core/workflows/discuss-phase/modes/chain.md": "58125711f7c432ce", + "gsd-core/workflows/discuss-phase/modes/default.md": "4ada4fe332c5c985", + "gsd-core/workflows/discuss-phase/modes/power.md": "55b61b7dc83a58b2", + "gsd-core/workflows/discuss-phase/modes/text.md": "aeb56fe7b95dd786", + "gsd-core/workflows/discuss-phase/templates/checkpoint.json": "e3bc3dca49db59eb", + "gsd-core/workflows/discuss-phase/templates/context.md": "6cd929e989fe2b0f", + "gsd-core/workflows/discuss-phase/templates/discussion-log.md": "cd5d01c9ef84ab73", + "gsd-core/workflows/do.md": "fc00009666985144", + "gsd-core/workflows/docs-update.md": "a3c2ec2c856aaabc", + "gsd-core/workflows/edit-phase.md": "1666ca537fbef030", + "gsd-core/workflows/eval-review.md": "bb01b3300db963bc", + "gsd-core/workflows/execute-phase.md": "72d0692329295c0d", + "gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md": "579a3dcf8d69de31", + "gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md": "7ebb7d1af6082028", + "gsd-core/workflows/execute-phase/steps/post-merge-gate.md": "bbd704d6b6f0787b", + "gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md": "be84efbd71e1513e", + "gsd-core/workflows/execute-plan.md": "f6bc701c05bb7d70", + "gsd-core/workflows/explore.md": "7a9bb001b196409f", + "gsd-core/workflows/extract-learnings.md": "58ce8fbc17388788", + "gsd-core/workflows/fast.md": "0162075e44072447", + "gsd-core/workflows/forensics.md": "3ec427afe7ab8bb8", + "gsd-core/workflows/graduation.md": "5734685667d8f512", + "gsd-core/workflows/health.md": "644b42c215b1b00e", + "gsd-core/workflows/help.md": "08e1349950c5602a", + "gsd-core/workflows/help/modes/brief.md": "2e923b3ed635d495", + "gsd-core/workflows/help/modes/default.md": "91509fe03bad9795", + "gsd-core/workflows/help/modes/full.md": "fec340660fa7b2ff", + "gsd-core/workflows/help/modes/topic.md": "9f00f5f94c5497aa", + "gsd-core/workflows/import.md": "f5b3826c4cfb2682", + "gsd-core/workflows/inbox.md": "797c287852eb8957", + "gsd-core/workflows/ingest-docs.md": "6f0ddb1130216a7d", + "gsd-core/workflows/insert-phase.md": "238e327203e04ed1", + "gsd-core/workflows/list-phase-assumptions.md": "eb986c301d59f620", + "gsd-core/workflows/list-seeds.md": "a87319b154d0fb8c", + "gsd-core/workflows/list-workspaces.md": "ff4ad30adc55d5b8", + "gsd-core/workflows/manager.md": "072a9f2ba6084641", + "gsd-core/workflows/map-codebase.md": "6c89a16e0d9fddfe", + "gsd-core/workflows/milestone-summary.md": "1ced13b54a1fab0b", + "gsd-core/workflows/mvp-phase.md": "bcc8037866fa1d4d", + "gsd-core/workflows/new-milestone.md": "763c096ead93de90", + "gsd-core/workflows/new-project.md": "d6709238a3ada30c", + "gsd-core/workflows/new-workspace.md": "8a9ab30eac218190", + "gsd-core/workflows/next.md": "3ebbf30622521e76", + "gsd-core/workflows/node-repair.md": "07a1628e5a1ff96b", + "gsd-core/workflows/note.md": "1c1e466c764e3deb", + "gsd-core/workflows/pause-work.md": "5ce6a137bd9fc0f8", + "gsd-core/workflows/plan-milestone-gaps.md": "19911e87fd4185f2", + "gsd-core/workflows/plan-phase.md": "98210e414d5d9e0b", + "gsd-core/workflows/plan-review-convergence.md": "7e01c19b7b9a2aad", + "gsd-core/workflows/plant-seed.md": "9d36ecd08093a494", + "gsd-core/workflows/pr-branch.md": "cc28ab5cd9db16b9", + "gsd-core/workflows/profile-user.md": "fa3b87e1f2d71371", + "gsd-core/workflows/progress.md": "36f1d4621c1798a4", + "gsd-core/workflows/quick.md": "c05946f016806c5f", + "gsd-core/workflows/reapply-patches.md": "04885b2129cbb457", + "gsd-core/workflows/remove-phase.md": "24559d23e008c9c3", + "gsd-core/workflows/remove-workspace.md": "e958c1e932aef492", + "gsd-core/workflows/resume-project.md": "c5731b8aabed70f9", + "gsd-core/workflows/review.md": "77124fdbb3e3bc7e", + "gsd-core/workflows/scan.md": "8aa95448b1ba4a4a", + "gsd-core/workflows/secure-phase.md": "550152cd82c25c00", + "gsd-core/workflows/session-report.md": "2e5b1205324ddefa", + "gsd-core/workflows/settings-advanced.md": "8381d23e29081352", + "gsd-core/workflows/settings-integrations.md": "ff9af62cdc5c7908", + "gsd-core/workflows/settings.md": "38a801a7b89a9379", + "gsd-core/workflows/ship.md": "ddfd8e847836dd94", + "gsd-core/workflows/sketch-wrap-up.md": "67e90c60062d0d5f", + "gsd-core/workflows/sketch.md": "7418628ba45dd6c0", + "gsd-core/workflows/spec-phase.md": "e8ed811cd67076b7", + "gsd-core/workflows/spike-wrap-up.md": "f8e39782c6e42ef7", + "gsd-core/workflows/spike.md": "aa5934044317ba7a", + "gsd-core/workflows/stats.md": "c49d3de15375d04b", + "gsd-core/workflows/sync-skills.md": "b505e6f8331c0918", + "gsd-core/workflows/thread.md": "5ad6ddb308909770", + "gsd-core/workflows/transition.md": "9a49f9c48805f666", + "gsd-core/workflows/ui-phase.md": "54e01f1973450a3c", + "gsd-core/workflows/ui-review.md": "fea93c281767f8d7", + "gsd-core/workflows/ultraplan-phase.md": "a164cc6aa4fb5bbb", + "gsd-core/workflows/undo.md": "18dec684fb1076f9", + "gsd-core/workflows/update.md": "cbf978622ad0f577", + "gsd-core/workflows/validate-phase.md": "a36cf2c688c261ac", + "gsd-core/workflows/verify-phase.md": "6adfc47bee438b5d", + "gsd-core/workflows/verify-work.md": "7586bdeeeb9ecba6", + "scripts/changeset/README.md": "86ff89331dfd94b2", + "scripts/changeset/cli.cjs": "68f92a344b199271", + "scripts/changeset/github-release-notes.cjs": "795677f0c009b132", + "scripts/changeset/lint.cjs": "0066faed159154f0", + "scripts/changeset/new.cjs": "4991e21fd17f5541", + "scripts/changeset/parse.cjs": "f9a949cbcab56445", + "scripts/changeset/render.cjs": "e47bc3e1587c3cae", + "scripts/changeset/serialize.cjs": "ac0b8fe6f87cdb0e", + "scripts/fix-slash-commands.cjs": "0519742531ff3529", + "scripts/lib/allowlist-ratchet.cjs": "ffaceaac3efc2660", + "scripts/lib/cli-exit.cjs": "612d0c372c75b7e7" +} diff --git a/tests/getdirname-registry-derivation.test.cjs b/tests/getdirname-registry-derivation.test.cjs new file mode 100644 index 000000000..99bd36875 --- /dev/null +++ b/tests/getdirname-registry-derivation.test.cjs @@ -0,0 +1,93 @@ +'use strict'; +/** + * Drift-guard: getDirName must be derived from the capability registry. + * Verifies: + * 1. For every known runtime id, getDirName(id) equals the hardcoded golden + * expected map — a pinned oracle that catches BOTH formula bugs AND + * unintended registry drift (adding/removing a runtime or changing its + * localConfigDir forces a deliberate golden-map update here). + * 2. getDirName('unknown') and getDirName('') fall back to '.claude'. + * 3. Every registry runtime entry has a non-empty dot-dir localConfigDir string — + * cross-check from a different angle than the production derivation formula. + * + * ADR-1239 Phase B (#1679). + * Behavioral tests only: assert on returned values, no source-grep. + */ + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const runtimeNamePolicy = require('../gsd-core/bin/lib/runtime-name-policy.cjs'); +const registry = require('../gsd-core/bin/lib/capability-registry.cjs'); + +const { getDirName } = runtimeNamePolicy; + +// Golden oracle: hardcoded expected map of all 16 runtime ids to their local config dir. +// A pinned expected value in a TEST is correct — the test IS the oracle (non-circular). +// Only PRODUCTION code should derive dynamically from the registry. +// If this map diverges from getDirName output, either the formula is wrong +// OR the registry changed — both require a deliberate golden-map update here. +const GOLDEN_DIR_MAP = { + claude: '.claude', + copilot: '.github', + opencode: '.opencode', + gemini: '.gemini', + kilo: '.kilo', + codex: '.codex', + antigravity: '.agents', + cursor: '.cursor', + windsurf: '.windsurf', + augment: '.augment', + trae: '.trae', + qwen: '.qwen', + hermes: '.hermes', + kimi: '.kimi-code', + codebuddy: '.codebuddy', + cline: '.cline', +}; + +test('getDirName: golden map matches for all 16 known runtime ids', () => { + for (const [id, expected] of Object.entries(GOLDEN_DIR_MAP)) { + const actual = getDirName(id); + assert.strictEqual( + actual, + expected, + `getDirName('${id}') diverged from golden.\n` + + ` actual: ${JSON.stringify(actual)}\n` + + ` expected: ${JSON.stringify(expected)}`, + ); + } +}); + +test('drift guard: registry runtime id set EXACTLY equals the golden map (adding/removing a runtime forces a golden update)', () => { + // Without this, a newly-added runtime would pass (its value never checked) and + // removing `claude` could pass via the .claude fallback. Pin the set both ways. + const registryIds = Object.keys(registry.runtimes).sort(); + const goldenIds = Object.keys(GOLDEN_DIR_MAP).sort(); + assert.deepEqual(registryIds, goldenIds, + 'registry.runtimes id set must exactly match GOLDEN_DIR_MAP — update the golden map when adding/removing a runtime'); +}); + +test('getDirName fallback: unknown runtime returns ".claude"', () => { + assert.strictEqual(getDirName('unknown'), '.claude', + 'getDirName("unknown") must return ".claude" (default fallback)'); +}); + +test('getDirName fallback: empty string returns ".claude"', () => { + assert.strictEqual(getDirName(''), '.claude', + 'getDirName("") must return ".claude" (empty-input fallback)'); +}); + +test('registry cross-check: every runtimes[id].runtime.localConfigDir is a non-empty dot-dir string', () => { + for (const [id, entry] of Object.entries(registry.runtimes)) { + if (!entry || typeof entry !== 'object') continue; + const runtimeBlock = entry.runtime; + if (!runtimeBlock || typeof runtimeBlock !== 'object') continue; + const dir = runtimeBlock.localConfigDir; + assert.strictEqual(typeof dir, 'string', + `registry.runtimes['${id}'].runtime.localConfigDir must be a string (got: ${typeof dir})`); + assert.ok(dir.length > 0, + `registry.runtimes['${id}'].runtime.localConfigDir must be non-empty`); + assert.ok(dir.startsWith('.'), + `registry.runtimes['${id}'].runtime.localConfigDir must start with '.' (got: ${JSON.stringify(dir)})`); + } +}); diff --git a/tests/golden-install-parity.test.cjs b/tests/golden-install-parity.test.cjs new file mode 100644 index 000000000..f70458669 --- /dev/null +++ b/tests/golden-install-parity.test.cjs @@ -0,0 +1,178 @@ +'use strict'; + +/** + * golden-install-parity.test.cjs — ADR-1239 Phase B safety-net harness. + * + * Captures a byte-stable manifest of every file emitted by the installer for + * all 16 runtimes, so a later PR moving installRuntimeArtifacts can prove + * byte-identical output parity. + * + * ## Determinism invariants (empirically established pre-Phase-B) + * + * After replacing every occurrence of the temp root path with the literal + * '' in file contents, the install output is byte-identical run-to-run + * for ALL files EXCEPT exactly two volatile metadata files that are EXCLUDED + * from the parity manifest: + * - gsd-file-manifest.json (timestamp + install-time absolute paths) + * - gsd-install-state.json (install-time absolute paths) + * + * Everything else (≈545–616 files per runtime) is deterministic. + * + * ## UPDATE mode + * + * Run with UPDATE_GOLDEN=1 to (re-)capture fixtures: + * UPDATE_GOLDEN=1 node --test tests/golden-install-parity.test.cjs + */ + +const { test, before } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const { execFileSync } = require('node:child_process'); + +const { cleanup } = require('./helpers.cjs'); +const { walk, RUNTIME_META, runMinimalInstall, BUILD_SCRIPT } = require('./helpers/install-shared.cjs'); + +// hooks/dist is gitignored and built (DEFECT.HOOKS-DIST-SCOPED-CI). The scoped +// CI test lane does not run build:hooks, so a real install there emits no hooks/ +// dir — making the golden (captured with hooks built) report "removed (N) hooks/…". +// Build it idempotently here so the harness is lane-independent (mirrors the +// pattern in bug-1834-sh-hooks-installed and install-minimal-hooks). +before(() => { + execFileSync(process.execPath, [BUILD_SCRIPT], { encoding: 'utf-8', stdio: 'pipe' }); +}); + +const UPDATE = process.env.UPDATE_GOLDEN === '1'; + +const FIXTURE_DIR = path.join(__dirname, 'fixtures', 'golden-install-parity'); + +// Volatile metadata files always excluded from the parity manifest. +const VOLATILE_FILES = new Set(['gsd-file-manifest.json', 'gsd-install-state.json']); + +// Hook-registration config files excluded from the parity manifest. These are +// written by the hook/permission install path (applySettingsJsonHooks / +// finishInstall) — NOT by installRuntimeArtifacts, so they are outside the scope +// of the engine deep-move this harness guards. They also embed the resolved +// node-runner invocation, whose FORM (absolute-quoted "/abs/bin/node" on macOS +// vs bare `node` resolved from PATH on Linux/CI) — not just the binary path — +// varies by platform and cannot be normalized to a single sentinel reliably. +// Their content is asserted directly by the dedicated hook tests +// (install-minimal-hooks, sh-hook-paths, codex-config, etc.). Matched by basename. +// settings.json = Claude/Antigravity/Augment/etc. hook surface; hooks.json = +// Codex/Cursor hook surface — both embed the platform-varying node-runner command. +const HOOK_CONFIG_FILES = new Set(['settings.json', 'hooks.json']); + +// Path prefixes excluded from the parity manifest. `gsd-core/bin/lib/` holds the +// tsc-built runtime artifacts (compiled from src/*.cts) that the install COPIES +// verbatim — they are NOT produced by installRuntimeArtifacts (the move's parity +// scope), and their exact bytes depend on the BUILD environment (a clean tsc +// build vs a stale incremental one yields different output for unchanged sources). +// Including them made the golden non-portable: CI's clean build legitimately +// differs from a local incremental build for modules the PR never touched +// (e.g. milestone.cjs, roadmap.cjs). The .cts sources are type-checked + drift- +// guarded + coverage-gated elsewhere; this harness asserts the CONVERTED artifact +// output (skills/commands/agents) that the engine actually emits. +const EXCLUDED_PREFIXES = ['gsd-core/bin/lib/']; + +/** + * Build a deterministic hash-map of all non-volatile files under configDir. + * + * For each file: + * - rel = POSIX-slash relative path from configDir + * - hash = sha256(content with root replaced by '').slice(0,16) + * + * Returns a plain object with sorted keys for stable JSON comparison. + * + * @param {string} configDir - absolute path to the installed runtime config dir + * @param {string} root - temp root path to replace with '' + * @returns {{ [rel: string]: string }} + */ +function buildParityManifest(configDir, root) { + const allFiles = walk(configDir); + const unsorted = {}; + + for (const full of allFiles) { + // Build POSIX-style relative path for cross-platform stability + const rel = path.relative(configDir, full).split(path.sep).join('/'); + + if (VOLATILE_FILES.has(rel)) continue; + if (HOOK_CONFIG_FILES.has(path.basename(rel))) continue; + if (EXCLUDED_PREFIXES.some((p) => rel.startsWith(p))) continue; + + const content = fs.readFileSync(full); + // Normalize every occurrence of the temp root so hashes are stable across runs. + // The only other platform-varying content (the node-runner command form) lives + // exclusively in the excluded HOOK_CONFIG_FILES, so no further normalization is + // needed — a scan of all 16 installs confirmed no other file embeds it. + const normalized = content.toString('utf8').split(root).join(''); + const hash = crypto.createHash('sha256').update(normalized).digest('hex').slice(0, 16); + unsorted[rel] = hash; + } + + // Reconstruct with sorted keys for stable JSON serialisation + const sorted = {}; + for (const key of Object.keys(unsorted).sort()) { + sorted[key] = unsorted[key]; + } + return sorted; +} + +// Ensure the fixture directory exists (needed for UPDATE mode) +if (UPDATE) { + fs.mkdirSync(FIXTURE_DIR, { recursive: true }); +} + +const runtimes = Object.keys(RUNTIME_META); + +for (const runtime of runtimes) { + test(`golden parity — ${runtime}`, async (t) => { + if (process.platform === 'win32') { + t.skip('install output is platform-specific on Windows (backslash paths); parity is asserted on macOS + Linux'); + return; + } + const { configDir, root } = runMinimalInstall({ runtime, scope: 'global' }); + let actual; + try { + actual = buildParityManifest(configDir, root); + } finally { + cleanup(root); + } + + const fixturePath = path.join(FIXTURE_DIR, `${runtime}.json`); + + if (UPDATE) { + fs.writeFileSync(fixturePath, JSON.stringify(actual, null, 2) + '\n', 'utf8'); + const fileCount = Object.keys(actual).length; + // Report to stdout so the capture run is self-documenting + process.stdout.write(` [UPDATE] ${runtime}: wrote ${fileCount} file hashes → ${fixturePath}\n`); + return; + } + + // Assert mode: compare against golden fixture + if (!fs.existsSync(fixturePath)) { + assert.fail( + `Golden fixture missing for runtime '${runtime}': ${fixturePath}\n` + + 'Run UPDATE_GOLDEN=1 node --test tests/golden-install-parity.test.cjs to capture.' + ); + } + + const golden = JSON.parse(fs.readFileSync(fixturePath, 'utf8')); + + const goldenKeys = new Set(Object.keys(golden)); + const actualKeys = new Set(Object.keys(actual)); + + const added = [...actualKeys].filter(k => !goldenKeys.has(k)); + const removed = [...goldenKeys].filter(k => !actualKeys.has(k)); + const changed = [...actualKeys].filter(k => goldenKeys.has(k) && actual[k] !== golden[k]); + + if (added.length > 0 || removed.length > 0 || changed.length > 0) { + const lines = [`Parity mismatch for runtime '${runtime}':`]; + if (added.length) lines.push(` added (${added.length}): ${added.join(', ')}`); + if (removed.length) lines.push(` removed (${removed.length}): ${removed.join(', ')}`); + if (changed.length) lines.push(` changed (${changed.length}): ${changed.join(', ')}`); + lines.push('Run UPDATE_GOLDEN=1 to recapture if the change is intentional.'); + assert.deepEqual(actual, golden, lines.join('\n')); + } + }); +} diff --git a/tests/gsd-check-update-worker-platform-gate.test.cjs b/tests/gsd-check-update-worker-platform-gate.test.cjs index f307c24fd..7547ba6df 100644 --- a/tests/gsd-check-update-worker-platform-gate.test.cjs +++ b/tests/gsd-check-update-worker-platform-gate.test.cjs @@ -41,7 +41,7 @@ const PROJECTION_PATH = path.join( function codeOnly(file) { return fs.readFileSync(file, 'utf8') .replace(/\/\*[\s\S]*?\*\//g, '') - .replace(/(^|[^:])\/\/[^\n]*/g, '$1'); + .replace(/(^|[^:])\/\/[^\r\n]*/g, '$1'); } describe('execNpm: Windows npm spawn platform gate (PR #3102, relocated #498)', () => { diff --git a/tests/gsd-researcher-app-aware.test.cjs b/tests/gsd-researcher-app-aware.test.cjs index 18f898ae0..5c8bc501f 100644 --- a/tests/gsd-researcher-app-aware.test.cjs +++ b/tests/gsd-researcher-app-aware.test.cjs @@ -55,7 +55,7 @@ describe('phase-researcher: Architectural Responsibility Mapping', () => { test('step is a pure reasoning step with no tool calls', () => { // Extract the ARM section content (between the ARM heading and the next ## Step heading) - const armHeadingMatch = content.match(/## Step 1\.5[^\n]*Architectural Responsibility Map/); + const armHeadingMatch = content.match(/## Step 1\.5[^\r\n]*Architectural Responsibility Map/); assert.ok(armHeadingMatch, 'Must have a Step 1.5 heading for Architectural Responsibility Mapping'); const armStart = content.indexOf(armHeadingMatch[0]); diff --git a/tests/gsd-tools-path-refs.test.cjs b/tests/gsd-tools-path-refs.test.cjs index 1079d59d3..a3c2922c2 100644 --- a/tests/gsd-tools-path-refs.test.cjs +++ b/tests/gsd-tools-path-refs.test.cjs @@ -30,7 +30,7 @@ describe('command files: gsd-tools path references (#1766)', () => { for (const file of files) { const content = fs.readFileSync(file, 'utf-8'); - const lines = content.split('\n'); + const lines = content.split(/\r?\n/); for (let i = 0; i < lines.length; i++) { if (/\bgsd-sdk\s+query\b|\$GSD_SDK\s+query/.test(lines[i])) { violations.push(`${rel(file)}:${i + 1}: ${lines[i].trim()}`); diff --git a/tests/hardcoded-paths.test.cjs b/tests/hardcoded-paths.test.cjs index ed7b84533..c7e286dd0 100644 --- a/tests/hardcoded-paths.test.cjs +++ b/tests/hardcoded-paths.test.cjs @@ -59,7 +59,7 @@ function scanFiles(files, pattern, _description) { const failures = []; for (const file of files) { const content = fs.readFileSync(file, 'utf8'); - const lines = content.split('\n'); + const lines = content.split(/\r?\n/); for (let i = 0; i < lines.length; i++) { const line = lines[i]; const trimmed = line.trimStart(); @@ -105,7 +105,7 @@ describe('no hardcoded /home/ absolute paths', () => { test('no /home// paths in string literals', () => { // Requires: quote + /home/ + non-slash chars (the username) + / // This avoids matching things like regex patterns /^home/ - const homePath = /['"`]\/home\/[^/\s'"` \n]+\//; + const homePath = /['"`]\/home\/[^/\s'"` \r\n]+\//; const failures = scanFiles(sourceFiles, homePath); assert.deepStrictEqual( failures, [], @@ -122,7 +122,7 @@ describe('no hardcoded /home/ absolute paths', () => { describe('no hardcoded /Users/ absolute paths', () => { test('no /Users// paths in string literals', () => { // Requires: quote + /Users/ + username chars + / - const usersPath = /['"`]\/Users\/[^/\s'"` \n]+\//; + const usersPath = /['"`]\/Users\/[^/\s'"` \r\n]+\//; const failures = scanFiles(sourceFiles, usersPath); assert.deepStrictEqual( failures, [], diff --git a/tests/hermes-skills-migration.test.cjs b/tests/hermes-skills-migration.test.cjs index 92b0a9fa1..a276f41e3 100644 --- a/tests/hermes-skills-migration.test.cjs +++ b/tests/hermes-skills-migration.test.cjs @@ -21,8 +21,9 @@ const fs = require('fs'); const { convertClaudeCommandToClaudeSkill, - installRuntimeArtifacts, } = require('../bin/install.js'); + +const { installRuntimeArtifacts } = require('../gsd-core/bin/lib/install-engine.cjs'); const { parseFrontmatter, cleanup } = require('./helpers.cjs'); const pkg = require('../package.json'); @@ -176,7 +177,7 @@ describe('Hermes Agent: installRuntimeArtifacts', () => { assert.ok(fm.description && fm.description.length > 0, 'description present and non-empty'); assert.strictEqual(fm.version, pkg.version, `Hermes SKILL.md must declare version (got ${JSON.stringify(fm.version)})`); - assert.ok(/^allowed-tools:\s*\n(?:\s+-\s+\S+\n?)+/m.test(content), + assert.ok(/^allowed-tools:\s*\r?\n(?:\s+-\s+\S+\r?\n?)+/m.test(content), 'allowed-tools rendered as YAML block list'); assert.ok(content.includes(''), 'body content preserved'); }); @@ -316,7 +317,7 @@ describe('Hermes Agent: SKILL.md format validation', () => { assert.strictEqual(fm.version, pkg.version, 'version matches package.json'); assert.strictEqual(fm.agent, 'gsd-code-reviewer', 'agent preserved'); assert.strictEqual(fm['argument-hint'], '[PR number or branch]', 'argument-hint preserved and unquoted'); - assert.ok(/^allowed-tools:\s*\n(?:\s+-\s+\S+\n?)+/m.test(result), + assert.ok(/^allowed-tools:\s*\r?\n(?:\s+-\s+\S+\r?\n?)+/m.test(result), 'allowed-tools rendered as YAML block list'); }); diff --git a/tests/host-integration-descriptors.test.cjs b/tests/host-integration-descriptors.test.cjs new file mode 100644 index 000000000..146c5c1c4 --- /dev/null +++ b/tests/host-integration-descriptors.test.cjs @@ -0,0 +1,400 @@ +'use strict'; + +/** + * ADR-1239 Phase A: Descriptor tests — validate that all 16 role:runtime + * capability descriptors have correct hostIntegration axes, pass the validator, + * and negotiate correctly via the host-integration module. + * + * Expectations are derived from the generated capability registry and + * .host-cli-final.json (source of truth). Values are verbatim; 'undocumented' + * sentinels fail-closed in negotiation (safe documented default, never propagate). + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); + +const { + negotiateHostCapabilities, + profileOf, + shouldFlattenDispatch, +} = require(path.join(__dirname, '../gsd-core/bin/lib/host-integration.cjs')); + +const registry = require(path.join(__dirname, '../gsd-core/bin/lib/capability-registry.cjs')); + +const { + validateCapability, +} = require(path.join(__dirname, '../gsd-core/bin/lib/capability-validator.cjs')); + +// All 8 scalar hostIntegration axis keys +const SCALAR_AXES = ['embeddingMode', 'commandSurface', 'modelMode', 'hookBus', 'stateIO', 'transport', 'runtime']; +// All 6 dispatch sub-keys (includes backgroundDispatch added in feat/1679-dispatch-flatten) +const DISPATCH_KEYS = ['namedDispatch', 'nested', 'maxDepth', 'background', 'subagentToolkit', 'backgroundDispatch']; + +// All 16 runtime IDs (ordered alphabetically) +const RUNTIME_IDS = [ + 'antigravity', 'augment', 'claude', 'cline', 'codebuddy', + 'codex', 'copilot', 'cursor', 'gemini', 'hermes', + 'kilo', 'kimi', 'opencode', 'qwen', 'trae', 'windsurf', +]; + +// Contract-pinned profile split (derived from .host-cli-final.json): +// programmatic-cli: claude, cline, cursor, hermes, kilo, kimi, opencode, qwen, trae (9) +// declarative-cli: antigravity, augment, codebuddy, codex, copilot, gemini, windsurf (7) +// ide: 0 +const EXPECTED_PROFILES = { + claude: 'programmatic-cli', + cline: 'programmatic-cli', + cursor: 'programmatic-cli', + hermes: 'programmatic-cli', + kilo: 'programmatic-cli', + kimi: 'programmatic-cli', + opencode: 'programmatic-cli', + qwen: 'programmatic-cli', + trae: 'programmatic-cli', + antigravity: 'declarative-cli', + augment: 'declarative-cli', + codebuddy: 'declarative-cli', + codex: 'declarative-cli', + copilot: 'declarative-cli', + gemini: 'declarative-cli', + windsurf: 'declarative-cli', +}; + +describe('ADR-1239 Phase A: hostIntegration descriptors', () => { + // ─── Registry shape ────────────────────────────────────────────────────────── + + test('registry.runtimes contains all 16 expected runtime ids', () => { + for (const id of RUNTIME_IDS) { + assert.ok( + Object.prototype.hasOwnProperty.call(registry.runtimes, id), + 'registry.runtimes must contain "' + id + '"', + ); + } + assert.strictEqual( + Object.keys(registry.runtimes).length, + 16, + 'registry.runtimes must have exactly 16 entries', + ); + }); + + // ─── Per-runtime assertions ─────────────────────────────────────────────────── + + for (const id of RUNTIME_IDS) { + describe('runtime: ' + id, () => { + const cap = registry.runtimes[id]; + const hi = cap && cap.runtime && cap.runtime.hostIntegration; + + // (i) Validator passes with zero errors + test('(i) validateCapability returns zero errors', () => { + const errors = validateCapability(cap, id); + assert.deepEqual( + errors, + [], + id + ': validateCapability must return no errors, got: ' + JSON.stringify(errors), + ); + }); + + // (ii) hostIntegration object is present with all required keys + test('(ii) cap.runtime.hostIntegration is present with all 8 axis keys and 6 dispatch sub-keys', () => { + assert.ok( + hi !== undefined && hi !== null && typeof hi === 'object', + id + ': cap.runtime.hostIntegration must be a non-null object', + ); + // All 8 scalar axes present + for (const axis of SCALAR_AXES) { + assert.ok( + Object.prototype.hasOwnProperty.call(hi, axis), + id + ': hostIntegration must have axis "' + axis + '"', + ); + } + // dispatch is an object + assert.ok( + hi.dispatch !== null && typeof hi.dispatch === 'object', + id + ': hostIntegration.dispatch must be a non-null object', + ); + // All 5 dispatch sub-keys present + for (const key of DISPATCH_KEYS) { + assert.ok( + Object.prototype.hasOwnProperty.call(hi.dispatch, key), + id + ': hostIntegration.dispatch must have key "' + key + '"', + ); + } + }); + + // (iii) negotiateHostCapabilities does not throw and behaves correctly + test('(iii) negotiateHostCapabilities: documented scalars pass through; undocumented scalars degrade with warning', () => { + assert.ok(hi, id + ': hostIntegration must exist to negotiate'); + let result; + assert.doesNotThrow(() => { + result = negotiateHostCapabilities(hi); + }, id + ': negotiateHostCapabilities must not throw'); + + const eff = result.effective; + + // For each scalar axis: if declared !== 'undocumented', effective === declared + // If declared === 'undocumented', effective !== 'undocumented' (safe default) and + // warnings must mention that axis. + for (const axis of SCALAR_AXES) { + const declared = hi[axis]; + if (declared !== 'undocumented') { + assert.strictEqual( + eff[axis], + declared, + id + ': effective.' + axis + ' must equal declared (' + JSON.stringify(declared) + '), got: ' + JSON.stringify(eff[axis]), + ); + } else { + // fail-closed: effective must be a documented safe default, not 'undocumented' + assert.notStrictEqual( + eff[axis], + 'undocumented', + id + ': effective.' + axis + ' must NOT be "undocumented" (fail-closed)', + ); + // warnings must mention this axis + const mentionsAxis = result.warnings.some((w) => w.includes(axis)); + assert.ok( + mentionsAxis, + id + ': result.warnings must mention axis "' + axis + '" when declared is undocumented, got: ' + JSON.stringify(result.warnings), + ); + } + } + }); + + // (iii-b) dispatch negotiation for namedDispatch + test('(iii-b) dispatch.namedDispatch negotiation', () => { + assert.ok(hi, id + ': hostIntegration must exist to negotiate'); + const result = negotiateHostCapabilities(hi); + + const declaredND = hi.dispatch && hi.dispatch.namedDispatch; + + if (declaredND === true) { + // documented as true → effective must be true + assert.strictEqual( + result.effective.dispatch.namedDispatch, + true, + id + ': effective.dispatch.namedDispatch must be true when declared is true', + ); + } else if (declaredND === 'undocumented') { + // undocumented → fail-closed: effective namedDispatch must be false + assert.strictEqual( + result.effective.dispatch.namedDispatch, + false, + id + ': effective.dispatch.namedDispatch must be false when declared is "undocumented" (fail-closed)', + ); + // dispatch.effectiveLevel must be 'absent' (no named dispatch) + assert.strictEqual( + result.points.dispatch.effectiveLevel, + 'absent', + id + ': points.dispatch.effectiveLevel must be "absent" when namedDispatch is undocumented', + ); + } + }); + + // (iv) profileOf returns expected profile + test('(iv) profileOf returns expected profile', () => { + assert.ok(hi, id + ': hostIntegration must exist to profile'); + const profile = profileOf(hi); + assert.ok( + profile !== null, + id + ': profileOf must return a non-null profile', + ); + assert.strictEqual( + profile, + EXPECTED_PROFILES[id], + id + ': profileOf must return "' + EXPECTED_PROFILES[id] + '" (got: "' + profile + '")', + ); + }); + }); + } + + // ─── Contract-pin profile split ─────────────────────────────────────────────── + + test('contract-pin: exactly 9 programmatic-cli, 7 declarative-cli, 0 ide', () => { + const counts = { 'programmatic-cli': 0, 'declarative-cli': 0, 'ide': 0 }; + for (const id of RUNTIME_IDS) { + const cap = registry.runtimes[id]; + const hi = cap && cap.runtime && cap.runtime.hostIntegration; + assert.ok(hi, id + ': hostIntegration must exist for profile count'); + const profile = profileOf(hi); + assert.ok(profile !== null, id + ': profileOf must be non-null'); + if (counts[profile] !== undefined) { + counts[profile]++; + } + } + assert.strictEqual(counts['programmatic-cli'], 9, 'Must have exactly 9 programmatic-cli runtimes'); + assert.strictEqual(counts['declarative-cli'], 7, 'Must have exactly 7 declarative-cli runtimes'); + assert.strictEqual(counts['ide'], 0, 'Must have exactly 0 ide runtimes'); + }); + + // ─── backgroundDispatch presence ───────────────────────────────────────────── + + test('every runtime descriptor has dispatch.backgroundDispatch (boolean or "undocumented")', () => { + for (const id of RUNTIME_IDS) { + const cap = registry.runtimes[id]; + const dispatch = cap && cap.runtime && cap.runtime.hostIntegration && cap.runtime.hostIntegration.dispatch; + assert.ok( + dispatch !== null && typeof dispatch === 'object', + id + ': hostIntegration.dispatch must be an object', + ); + assert.ok( + Object.prototype.hasOwnProperty.call(dispatch, 'backgroundDispatch'), + id + ': dispatch must have a backgroundDispatch key', + ); + const v = dispatch.backgroundDispatch; + assert.ok( + v === true || v === false || v === 'undocumented', + id + ': dispatch.backgroundDispatch must be true, false, or "undocumented", got: ' + JSON.stringify(v), + ); + } + }); + + // ─── shouldFlattenDispatch per-host (#853 discriminator) ───────────────────── + + // Expected: false (may background) for codex and cursor ONLY; true (must inline) for the other 14. + const EXPECTED_FLATTEN = { + antigravity: true, + augment: true, + claude: true, + cline: true, + codebuddy: true, + codex: false, + copilot: true, + cursor: false, + gemini: true, + hermes: true, + kilo: true, + kimi: true, + opencode: true, + qwen: true, + trae: true, + windsurf: true, + }; + + for (const id of RUNTIME_IDS) { + test('shouldFlattenDispatch(' + id + ') === ' + EXPECTED_FLATTEN[id], () => { + const cap = registry.runtimes[id]; + const dispatch = cap && cap.runtime && cap.runtime.hostIntegration && cap.runtime.hostIntegration.dispatch; + assert.ok(dispatch, id + ': dispatch must exist'); + const result = shouldFlattenDispatch(dispatch); + assert.strictEqual( + result, + EXPECTED_FLATTEN[id], + id + ': shouldFlattenDispatch must return ' + EXPECTED_FLATTEN[id] + ' (got: ' + result + ')', + ); + }); + } + + test('contract-pin: exactly 2 hosts are background-eligible (shouldFlattenDispatch === false): codex and cursor', () => { + const eligible = RUNTIME_IDS.filter((id) => { + const cap = registry.runtimes[id]; + const dispatch = cap && cap.runtime && cap.runtime.hostIntegration && cap.runtime.hostIntegration.dispatch; + return dispatch && shouldFlattenDispatch(dispatch) === false; + }); + assert.deepEqual( + eligible.slice().sort(), + ['codex', 'cursor'], + 'Exactly codex and cursor must be background-eligible, got: ' + JSON.stringify(eligible.sort()), + ); + }); + + test('contract-pin: spot-check claude→programmatic-cli, codex→declarative-cli, opencode→programmatic-cli, gemini→declarative-cli', () => { + const checks = [ + ['claude', 'programmatic-cli'], + ['codex', 'declarative-cli'], + ['opencode', 'programmatic-cli'], + ['gemini', 'declarative-cli'], + ]; + for (const [id, expectedProfile] of checks) { + const cap = registry.runtimes[id]; + const hi = cap && cap.runtime && cap.runtime.hostIntegration; + assert.ok(hi, id + ': hostIntegration must exist'); + const profile = profileOf(hi); + assert.strictEqual( + profile, + expectedProfile, + id + ': profileOf must return "' + expectedProfile + '" (got: "' + profile + '")', + ); + } + }); + + // ─── NEGATIVE cases ─────────────────────────────────────────────────────────── + + describe('NEGATIVE: invalid hostIntegration.embeddingMode triggers validator error', () => { + test('embeddingMode "bogus" produces a validator error naming embeddingMode', () => { + const cap = { + id: 'test-neg', + role: 'runtime', + version: '1.0.0', + title: 'Test Negative', + description: 'Negative case for hostIntegration validation.', + tier: 'core', + requires: [], + runtime: { + configHome: { kind: 'dot-home', name: '.test-neg', env: [] }, + configFormat: 'settings-json', + artifactLayout: { global: [], local: [] }, + commandStyle: 'slash-hyphen', + hooksSurface: 'settings-json', + hookEvents: 'claude', + sandboxTier: 'none', + supportTier: 1, + installSurface: 'settings-json', + writesSharedSettings: true, + permissionWriter: null, + extendedHookEvents: [], + hostIntegration: { + embeddingMode: 'bogus', + commandSurface: 'slash-file', + dispatch: { namedDispatch: true, nested: false, maxDepth: 1, background: false, subagentToolkit: 'full', backgroundDispatch: false }, + modelMode: 'passive', + hookBus: 'host', + stateIO: 'filesystem', + transport: 'mcp', + runtime: 'node', + }, + }, + }; + const errors = validateCapability(cap, 'test-neg'); + assert.ok(errors.length > 0, 'Expected validation errors for bogus embeddingMode'); + assert.ok( + errors.some((e) => e.includes('embeddingMode')), + 'At least one error must mention embeddingMode, got: ' + JSON.stringify(errors), + ); + }); + }); + + describe('NEGATIVE: missing hostIntegration produces required-object error', () => { + test('runtime body without hostIntegration produces the required-object error', () => { + const cap = { + id: 'test-missing-hi', + role: 'runtime', + version: '1.0.0', + title: 'Test Missing HI', + description: 'Negative case for missing hostIntegration.', + tier: 'core', + requires: [], + runtime: { + configHome: { kind: 'dot-home', name: '.test-missing-hi', env: [] }, + configFormat: 'settings-json', + artifactLayout: { global: [], local: [] }, + commandStyle: 'slash-hyphen', + hooksSurface: 'settings-json', + hookEvents: 'claude', + sandboxTier: 'none', + supportTier: 1, + installSurface: 'settings-json', + writesSharedSettings: true, + permissionWriter: null, + extendedHookEvents: [], + // hostIntegration intentionally absent + }, + }; + const errors = validateCapability(cap, 'test-missing-hi'); + assert.ok(errors.length > 0, 'Expected validation errors for missing hostIntegration'); + assert.ok( + errors.some((e) => e.includes('hostIntegration') && e.includes('required')), + 'At least one error must mention hostIntegration and required, got: ' + JSON.stringify(errors), + ); + }); + }); +}); diff --git a/tests/host-integration-validator-parity.test.cjs b/tests/host-integration-validator-parity.test.cjs new file mode 100644 index 000000000..eab47c458 --- /dev/null +++ b/tests/host-integration-validator-parity.test.cjs @@ -0,0 +1,432 @@ +'use strict'; + +/** + * ADR-1239 Phase A: Parity guard — validator VALID_* sets MUST exactly match + * HOST_INTEGRATION_AXES arrays from host-integration.cjs. + * + * If either side drifts, this test fails immediately (not silently). + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); + +const { + HOST_INTEGRATION_AXES, +} = require(path.join(__dirname, '../gsd-core/bin/lib/host-integration.cjs')); + +const { + _HOST_INTEGRATION_VOCAB, + validateCapability, +} = require(path.join(__dirname, '../gsd-core/bin/lib/capability-validator.cjs')); + +// Sort for deterministic comparison +function sorted(arr) { + return [...arr].sort(); +} + +// Minimal valid runtime capability descriptor (all documented values) +function makeMinimalRuntimeCap(overrides = {}) { + return { + id: 'test-runtime', + role: 'runtime', + title: 'Test Runtime', + description: 'Test runtime capability for parity tests', + tier: 'core', + requires: [], + version: '1.0.0', + runtime: { + configHome: { + kind: 'dot-home', + name: '.test-runtime', + env: [], + }, + configFormat: 'markdown', + artifactLayout: { + global: [], + local: [], + }, + commandStyle: 'slash-hyphen', + hooksSurface: 'none', + sandboxTier: 'none', + supportTier: 1, + installSurface: 'profile-marker-only', + localConfigDir: '.test-runtime', + writesSharedSettings: false, + permissionWriter: null, + extendedHookEvents: [], + hostIntegration: { + embeddingMode: 'imperative', + commandSurface: 'slash-file', + modelMode: 'passive', + hookBus: 'host', + stateIO: 'filesystem', + transport: 'mcp', + runtime: 'node', + dispatch: { + namedDispatch: true, + nested: false, + maxDepth: 1, + background: false, + subagentToolkit: 'full', + backgroundDispatch: false, + }, + }, + ...overrides, + }, + }; +} + +describe('ADR-1239 Phase A: host-integration validator parity', () => { + test('_HOST_INTEGRATION_VOCAB is exported from capability-validator.cjs', () => { + assert.ok( + _HOST_INTEGRATION_VOCAB !== undefined && _HOST_INTEGRATION_VOCAB !== null, + '_HOST_INTEGRATION_VOCAB must be exported from capability-validator.cjs', + ); + assert.strictEqual(typeof _HOST_INTEGRATION_VOCAB, 'object'); + }); + + test('embeddingMode: validator set === HOST_INTEGRATION_AXES.embeddingMode', () => { + assert.deepEqual( + sorted(_HOST_INTEGRATION_VOCAB.embeddingMode), + sorted(HOST_INTEGRATION_AXES.embeddingMode), + 'validator VALID_EMBEDDING_MODES must exactly match HOST_INTEGRATION_AXES.embeddingMode', + ); + }); + + test('commandSurface: validator set === HOST_INTEGRATION_AXES.commandSurface', () => { + assert.deepEqual( + sorted(_HOST_INTEGRATION_VOCAB.commandSurface), + sorted(HOST_INTEGRATION_AXES.commandSurface), + 'validator VALID_COMMAND_SURFACES must exactly match HOST_INTEGRATION_AXES.commandSurface', + ); + }); + + test('modelMode: validator set === HOST_INTEGRATION_AXES.modelMode', () => { + assert.deepEqual( + sorted(_HOST_INTEGRATION_VOCAB.modelMode), + sorted(HOST_INTEGRATION_AXES.modelMode), + 'validator VALID_MODEL_MODES must exactly match HOST_INTEGRATION_AXES.modelMode', + ); + }); + + test('hookBus: validator set === HOST_INTEGRATION_AXES.hookBus', () => { + assert.deepEqual( + sorted(_HOST_INTEGRATION_VOCAB.hookBus), + sorted(HOST_INTEGRATION_AXES.hookBus), + 'validator VALID_HOOK_BUSES must exactly match HOST_INTEGRATION_AXES.hookBus', + ); + }); + + test('stateIO: validator set === HOST_INTEGRATION_AXES.stateIO', () => { + assert.deepEqual( + sorted(_HOST_INTEGRATION_VOCAB.stateIO), + sorted(HOST_INTEGRATION_AXES.stateIO), + 'validator VALID_STATE_IO must exactly match HOST_INTEGRATION_AXES.stateIO', + ); + }); + + test('transport: validator set === HOST_INTEGRATION_AXES.transport', () => { + assert.deepEqual( + sorted(_HOST_INTEGRATION_VOCAB.transport), + sorted(HOST_INTEGRATION_AXES.transport), + 'validator VALID_TRANSPORTS must exactly match HOST_INTEGRATION_AXES.transport', + ); + }); + + test('runtime (axis): validator set === HOST_INTEGRATION_AXES.runtime (8 documented values)', () => { + assert.deepEqual( + sorted(_HOST_INTEGRATION_VOCAB.runtime), + sorted(HOST_INTEGRATION_AXES.runtime), + 'validator VALID_HOST_RUNTIMES must exactly match HOST_INTEGRATION_AXES.runtime', + ); + }); + + test('subagentToolkit: validator set === HOST_INTEGRATION_AXES.subagentToolkit', () => { + assert.deepEqual( + sorted(_HOST_INTEGRATION_VOCAB.subagentToolkit), + sorted(HOST_INTEGRATION_AXES.subagentToolkit), + 'validator VALID_SUBAGENT_TOOLKITS must exactly match HOST_INTEGRATION_AXES.subagentToolkit', + ); + }); + + test('all axis keys in HOST_INTEGRATION_AXES are covered by _HOST_INTEGRATION_VOCAB', () => { + const axisKeys = Object.keys(HOST_INTEGRATION_AXES).sort(); + const vocabKeys = Object.keys(_HOST_INTEGRATION_VOCAB).sort(); + assert.deepEqual( + vocabKeys, + axisKeys, + '_HOST_INTEGRATION_VOCAB must cover exactly the same axis keys as HOST_INTEGRATION_AXES', + ); + }); + + test('_HOST_INTEGRATION_VOCAB does NOT include "undocumented" (documented vocab only)', () => { + for (const [axis, values] of Object.entries(_HOST_INTEGRATION_VOCAB)) { + assert.ok( + !values.includes('undocumented'), + `_HOST_INTEGRATION_VOCAB.${axis} must not include "undocumented" (sentinel is NOT documented vocab)`, + ); + } + }); +}); + +// --------------------------------------------------------------------------- +// Behavioral: "undocumented" passes validator; bogus values still fail +// --------------------------------------------------------------------------- + +describe('ADR-1239 validator behavioral: undocumented sentinel passes, bogus fails', () => { + const SCALAR_AXES = ['embeddingMode', 'commandSurface', 'modelMode', 'hookBus', 'stateIO', 'transport', 'runtime']; + + for (const axis of SCALAR_AXES) { + test(`hostIntegration.${axis}:"undocumented" → ZERO validator errors`, () => { + const cap = makeMinimalRuntimeCap({ + hostIntegration: { + ...makeMinimalRuntimeCap().runtime.hostIntegration, + [axis]: 'undocumented', + }, + }); + const errors = validateCapability(cap, 'test-runtime'); + const hiErrors = errors.filter((e) => e.includes('hostIntegration.' + axis)); + assert.strictEqual(hiErrors.length, 0, + `"undocumented" for axis "${axis}" must produce no validator errors; got: ${hiErrors.join(', ')}`); + }); + + test(`hostIntegration.${axis}:"zzz" → produces a validator error`, () => { + const cap = makeMinimalRuntimeCap({ + hostIntegration: { + ...makeMinimalRuntimeCap().runtime.hostIntegration, + [axis]: 'zzz', + }, + }); + const errors = validateCapability(cap, 'test-runtime'); + const hiErrors = errors.filter((e) => e.includes('hostIntegration.' + axis)); + assert.ok(hiErrors.length > 0, + `bogus value "zzz" for axis "${axis}" must produce a validator error`); + }); + } + + test('dispatch.namedDispatch:"undocumented" → ZERO validator errors for that field', () => { + const cap = makeMinimalRuntimeCap({ + hostIntegration: { + ...makeMinimalRuntimeCap().runtime.hostIntegration, + dispatch: { + namedDispatch: 'undocumented', + nested: 'undocumented', + maxDepth: 'undocumented', + background: 'undocumented', + subagentToolkit: 'undocumented', + backgroundDispatch: 'undocumented', + }, + }, + }); + const errors = validateCapability(cap, 'test-runtime'); + const dispatchErrors = errors.filter((e) => e.includes('hostIntegration.dispatch')); + assert.strictEqual(dispatchErrors.length, 0, + `"undocumented" for all dispatch fields must produce no validator errors; got: ${dispatchErrors.join(', ')}`); + }); + + test('dispatch boolean fields: true/false still accepted', () => { + const cap = makeMinimalRuntimeCap(); + const errors = validateCapability(cap, 'test-runtime'); + const dispatchErrors = errors.filter((e) => e.includes('hostIntegration.dispatch')); + assert.strictEqual(dispatchErrors.length, 0, + `Valid boolean dispatch fields must produce no errors; got: ${dispatchErrors.join(', ')}`); + }); + + // Phase B: backgroundDispatch field validation + test('dispatch.backgroundDispatch:true → ZERO validator errors', () => { + const cap = makeMinimalRuntimeCap({ + hostIntegration: { + ...makeMinimalRuntimeCap().runtime.hostIntegration, + dispatch: { ...makeMinimalRuntimeCap().runtime.hostIntegration.dispatch, backgroundDispatch: true }, + }, + }); + const errors = validateCapability(cap, 'test-runtime'); + const bdErrors = errors.filter((e) => e.includes('backgroundDispatch')); + assert.strictEqual(bdErrors.length, 0, + `backgroundDispatch:true must produce no errors; got: ${bdErrors.join(', ')}`); + }); + + test('dispatch.backgroundDispatch:false → ZERO validator errors', () => { + const cap = makeMinimalRuntimeCap({ + hostIntegration: { + ...makeMinimalRuntimeCap().runtime.hostIntegration, + dispatch: { ...makeMinimalRuntimeCap().runtime.hostIntegration.dispatch, backgroundDispatch: false }, + }, + }); + const errors = validateCapability(cap, 'test-runtime'); + const bdErrors = errors.filter((e) => e.includes('backgroundDispatch')); + assert.strictEqual(bdErrors.length, 0, + `backgroundDispatch:false must produce no errors; got: ${bdErrors.join(', ')}`); + }); + + test('dispatch.backgroundDispatch:"undocumented" → ZERO validator errors', () => { + const cap = makeMinimalRuntimeCap({ + hostIntegration: { + ...makeMinimalRuntimeCap().runtime.hostIntegration, + dispatch: { ...makeMinimalRuntimeCap().runtime.hostIntegration.dispatch, backgroundDispatch: 'undocumented' }, + }, + }); + const errors = validateCapability(cap, 'test-runtime'); + const bdErrors = errors.filter((e) => e.includes('backgroundDispatch')); + assert.strictEqual(bdErrors.length, 0, + `backgroundDispatch:"undocumented" must produce no errors; got: ${bdErrors.join(', ')}`); + }); + + test('dispatch.backgroundDispatch:"zzz" → produces a validator error', () => { + const cap = makeMinimalRuntimeCap({ + hostIntegration: { + ...makeMinimalRuntimeCap().runtime.hostIntegration, + dispatch: { ...makeMinimalRuntimeCap().runtime.hostIntegration.dispatch, backgroundDispatch: 'zzz' }, + }, + }); + const errors = validateCapability(cap, 'test-runtime'); + const bdErrors = errors.filter((e) => e.includes('backgroundDispatch')); + assert.ok(bdErrors.length > 0, + `bogus value "zzz" for backgroundDispatch must produce a validator error`); + }); + + test('dispatch without backgroundDispatch key → validator error (required field — all 16 descriptors carry it)', () => { + // backgroundDispatch is now REQUIRED (matches sibling fields namedDispatch/nested/background/subagentToolkit/maxDepth). + const cap = makeMinimalRuntimeCap({ + hostIntegration: { + ...makeMinimalRuntimeCap().runtime.hostIntegration, + dispatch: (() => { + const d = { ...makeMinimalRuntimeCap().runtime.hostIntegration.dispatch }; + delete d.backgroundDispatch; + return d; + })(), + }, + }); + const errors = validateCapability(cap, 'test-runtime'); + const bdErrors = errors.filter((e) => e.includes('backgroundDispatch')); + assert.ok(bdErrors.length > 0, + `Missing backgroundDispatch (required field) must produce a validator error`); + }); +}); + +// --------------------------------------------------------------------------- +// Fix 3: validator must reject reserved keys on hostIntegration and dispatch +// --------------------------------------------------------------------------- + +describe('Fix 3: reserved-key guard on hostIntegration and hostIntegration.dispatch', () => { + // Base valid runtime body (all documented values, claude layout) + // We build it via JSON.parse to produce an own "__proto__" key that would + // normally be swallowed by a spread (JSON.parse always produces own props). + const BASE_RUNTIME_JSON = JSON.stringify({ + id: 'test-runtime', + role: 'runtime', + title: 'Test', + description: 'Test runtime', + tier: 'core', + requires: [], + version: '1.6.0', + engines: { gsd: '>=1.6.0' }, + runtime: { + configHome: { kind: 'dot-home', name: '.test', env: [] }, + configFormat: 'settings-json', + artifactLayout: { global: [], local: [] }, + commandStyle: 'slash-hyphen', + hooksSurface: 'settings-json', + hookEvents: 'claude', + sandboxTier: 'none', + supportTier: 1, + installSurface: 'settings-json', + localConfigDir: '.test-runtime', + writesSharedSettings: true, + permissionWriter: null, + extendedHookEvents: ['SubagentStop', 'Stop', 'PreCompact', 'FileChanged'], + hostIntegration: { + embeddingMode: 'imperative', + commandSurface: 'slash-file', + dispatch: { + namedDispatch: true, + nested: true, + maxDepth: 5, + background: true, + subagentToolkit: 'full', + backgroundDispatch: true, + }, + modelMode: 'passive', + hookBus: 'host', + stateIO: 'filesystem', + transport: 'mcp', + runtime: 'node', + }, + }, + }); + + test('baseline (no reserved keys) → ZERO errors', () => { + const cap = JSON.parse(BASE_RUNTIME_JSON); + const errors = validateCapability(cap, 'test-runtime'); + assert.strictEqual(errors.length, 0, + 'Baseline with no reserved keys must produce zero errors; got: ' + errors.join(', ')); + }); + + test('hostIntegration with own __proto__ key → error mentioning "reserved key" and "__proto__"', () => { + // Inject a raw __proto__ key via JSON string manipulation — JSON.parse gives it + // as an OWN property (unlike { ..., __proto__: ... } which sets prototype chain). + const json = BASE_RUNTIME_JSON.replace( + '"hostIntegration":{', + '"hostIntegration":{"__proto__":{"polluted":true},', + ); + const cap = JSON.parse(json); + // Verify the own-key is actually present (our assumption about JSON.parse) + assert.ok( + Object.prototype.hasOwnProperty.call(cap.runtime.hostIntegration, '__proto__'), + 'JSON.parse must produce an own __proto__ key on hostIntegration', + ); + const errors = validateCapability(cap, 'test-runtime'); + const reservedErrors = errors.filter((e) => e.includes('reserved key') && e.includes('__proto__')); + assert.ok(reservedErrors.length > 0, + 'Must produce an error mentioning "reserved key" and "__proto__" for hostIntegration; got: ' + errors.join(', ')); + }); + + test('hostIntegration with own "constructor" key → error mentioning "reserved key" and "constructor"', () => { + const json = BASE_RUNTIME_JSON.replace( + '"hostIntegration":{', + '"hostIntegration":{"constructor":"polluted",', + ); + const cap = JSON.parse(json); + assert.ok( + Object.prototype.hasOwnProperty.call(cap.runtime.hostIntegration, 'constructor'), + 'JSON.parse must produce an own constructor key on hostIntegration', + ); + const errors = validateCapability(cap, 'test-runtime'); + const reservedErrors = errors.filter((e) => e.includes('reserved key') && e.includes('constructor')); + assert.ok(reservedErrors.length > 0, + 'Must produce an error for "constructor" reserved key on hostIntegration; got: ' + errors.join(', ')); + }); + + test('hostIntegration.dispatch with own __proto__ key → error mentioning "reserved key" and "__proto__"', () => { + const json = BASE_RUNTIME_JSON.replace( + '"dispatch":{', + '"dispatch":{"__proto__":{"polluted":true},', + ); + const cap = JSON.parse(json); + assert.ok( + Object.prototype.hasOwnProperty.call(cap.runtime.hostIntegration.dispatch, '__proto__'), + 'JSON.parse must produce an own __proto__ key on dispatch', + ); + const errors = validateCapability(cap, 'test-runtime'); + const reservedErrors = errors.filter((e) => e.includes('reserved key') && e.includes('__proto__')); + assert.ok(reservedErrors.length > 0, + 'Must produce an error for "__proto__" reserved key on dispatch; got: ' + errors.join(', ')); + }); + + test('hostIntegration.dispatch with own "prototype" key → error mentioning "reserved key" and "prototype"', () => { + const json = BASE_RUNTIME_JSON.replace( + '"dispatch":{', + '"dispatch":{"prototype":{"polluted":true},', + ); + const cap = JSON.parse(json); + assert.ok( + Object.prototype.hasOwnProperty.call(cap.runtime.hostIntegration.dispatch, 'prototype'), + 'JSON.parse must produce an own prototype key on dispatch', + ); + const errors = validateCapability(cap, 'test-runtime'); + const reservedErrors = errors.filter((e) => e.includes('reserved key') && e.includes('prototype')); + assert.ok(reservedErrors.length > 0, + 'Must produce an error for "prototype" reserved key on dispatch; got: ' + errors.join(', ')); + }); +}); diff --git a/tests/host-integration.test.cjs b/tests/host-integration.test.cjs new file mode 100644 index 000000000..a4fd96cfd --- /dev/null +++ b/tests/host-integration.test.cjs @@ -0,0 +1,1037 @@ +'use strict'; + +/** + * Unit tests for host-integration.cjs (ADR-1239 Phase A). + * Pure, additive, no-I/O module — no temp dirs needed. + * Uses node:test + node:assert/strict. + * Requires the COMPILED artifact: ../gsd-core/bin/lib/host-integration.cjs + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); + +const hi = require('../gsd-core/bin/lib/host-integration.cjs'); +const { + PROTOCOL_VERSION, + HOST_INTEGRATION_AXES, + INTERFACE_POINTS, + PROFILE_BASELINES, + DEFAULT_ENGINE, + UNDOCUMENTED, + degradationFor, + profileOf, + negotiateHostCapabilities, +} = hi; + +// --------------------------------------------------------------------------- +// CONTRACT-PIN: constants and vocabulary +// --------------------------------------------------------------------------- + +describe('CONTRACT-PIN', () => { + test('PROTOCOL_VERSION === 1', () => { + assert.strictEqual(PROTOCOL_VERSION, 1); + }); + + test('HOST_INTEGRATION_AXES is frozen', () => { + assert.ok(Object.isFrozen(HOST_INTEGRATION_AXES), 'HOST_INTEGRATION_AXES must be frozen'); + }); + + test('each axis sub-array is frozen', () => { + for (const [axis, arr] of Object.entries(HOST_INTEGRATION_AXES)) { + assert.ok(Object.isFrozen(arr), `HOST_INTEGRATION_AXES.${axis} must be frozen`); + } + }); + + test('embeddingMode values (sorted)', () => { + assert.deepStrictEqual( + [...HOST_INTEGRATION_AXES.embeddingMode].sort(), + ['declarative', 'imperative'], + ); + }); + + test('commandSurface values (sorted)', () => { + assert.deepStrictEqual( + [...HOST_INTEGRATION_AXES.commandSurface].sort(), + ['palette', 'prose-only', 'slash-file', 'slash-programmatic', 'slash-toml'], + ); + }); + + test('modelMode values (sorted)', () => { + assert.deepStrictEqual( + [...HOST_INTEGRATION_AXES.modelMode].sort(), + ['active', 'passive'], + ); + }); + + test('hookBus values (sorted)', () => { + assert.deepStrictEqual( + [...HOST_INTEGRATION_AXES.hookBus].sort(), + ['engine', 'host', 'none'], + ); + }); + + test('stateIO values (sorted)', () => { + assert.deepStrictEqual( + [...HOST_INTEGRATION_AXES.stateIO].sort(), + ['filesystem', 'sandboxed-storage', 'session-log-append'], + ); + }); + + test('transport values (sorted)', () => { + assert.deepStrictEqual( + [...HOST_INTEGRATION_AXES.transport].sort(), + ['mcp', 'native-extension'], + ); + }); + + test('runtime values (sorted) — 8 documented values', () => { + assert.deepStrictEqual( + [...HOST_INTEGRATION_AXES.runtime].sort(), + ['bun', 'electron', 'go', 'node', 'other', 'python', 'rust', 'sandboxed-web'], + ); + }); + + test('UNDOCUMENTED === "undocumented"', () => { + assert.equal(UNDOCUMENTED, 'undocumented'); + }); + + test('subagentToolkit values (sorted)', () => { + assert.deepStrictEqual( + [...HOST_INTEGRATION_AXES.subagentToolkit].sort(), + ['full', 'read-only'], + ); + }); + + test('INTERFACE_POINTS frozen and contains expected values', () => { + assert.ok(Object.isFrozen(INTERFACE_POINTS), 'INTERFACE_POINTS must be frozen'); + const expected = ['command', 'dispatch', 'model', 'hooks', 'state', 'artifact'].sort(); + assert.deepStrictEqual([...INTERFACE_POINTS].sort(), expected); + }); +}); + +// --------------------------------------------------------------------------- +// degradationFor — happy path per enum value +// --------------------------------------------------------------------------- + +describe('degradationFor — happy path', () => { + test('command: slash-file → full', () => { + const r = degradationFor('command', { commandSurface: 'slash-file' }); + assert.strictEqual(r.level, 'full'); + assert.strictEqual(typeof r.fallback, 'string'); + }); + + test('command: slash-programmatic → full', () => { + const r = degradationFor('command', { commandSurface: 'slash-programmatic' }); + assert.strictEqual(r.level, 'full'); + }); + + test('command: slash-toml → degraded', () => { + const r = degradationFor('command', { commandSurface: 'slash-toml' }); + assert.strictEqual(r.level, 'degraded'); + }); + + test('command: palette → degraded', () => { + const r = degradationFor('command', { commandSurface: 'palette' }); + assert.strictEqual(r.level, 'degraded'); + }); + + test('command: prose-only → absent', () => { + const r = degradationFor('command', { commandSurface: 'prose-only' }); + assert.strictEqual(r.level, 'absent'); + assert.ok(r.fallback.length > 0, 'fallback must be non-empty for prose-only'); + }); + + test('model: active → full', () => { + const r = degradationFor('model', { modelMode: 'active' }); + assert.strictEqual(r.level, 'full'); + }); + + test('model: passive → degraded', () => { + const r = degradationFor('model', { modelMode: 'passive' }); + assert.strictEqual(r.level, 'degraded'); + }); + + test('hooks: host → full', () => { + const r = degradationFor('hooks', { hookBus: 'host' }); + assert.strictEqual(r.level, 'full'); + }); + + test('hooks: engine → degraded', () => { + const r = degradationFor('hooks', { hookBus: 'engine' }); + assert.strictEqual(r.level, 'degraded'); + }); + + test('hooks: none → absent', () => { + const r = degradationFor('hooks', { hookBus: 'none' }); + assert.strictEqual(r.level, 'absent'); + }); + + test('state: filesystem → full', () => { + const r = degradationFor('state', { stateIO: 'filesystem' }); + assert.strictEqual(r.level, 'full'); + }); + + test('state: sandboxed-storage → degraded', () => { + const r = degradationFor('state', { stateIO: 'sandboxed-storage' }); + assert.strictEqual(r.level, 'degraded'); + }); + + test('state: session-log-append → degraded', () => { + const r = degradationFor('state', { stateIO: 'session-log-append' }); + assert.strictEqual(r.level, 'degraded'); + }); + + test('artifact: slash-file → full', () => { + const r = degradationFor('artifact', { commandSurface: 'slash-file' }); + assert.strictEqual(r.level, 'full'); + }); + + test('artifact: slash-programmatic → full', () => { + const r = degradationFor('artifact', { commandSurface: 'slash-programmatic' }); + assert.strictEqual(r.level, 'full'); + }); + + test('artifact: slash-toml → degraded', () => { + const r = degradationFor('artifact', { commandSurface: 'slash-toml' }); + assert.strictEqual(r.level, 'degraded'); + }); + + test('artifact: prose-only → degraded', () => { + const r = degradationFor('artifact', { commandSurface: 'prose-only' }); + assert.strictEqual(r.level, 'degraded'); + }); + + test('artifact: palette → absent', () => { + const r = degradationFor('artifact', { commandSurface: 'palette' }); + assert.strictEqual(r.level, 'absent'); + }); + + // dispatch variants + test('dispatch: no namedDispatch → absent', () => { + const r = degradationFor('dispatch', { dispatch: { namedDispatch: false, nested: false, maxDepth: 0, background: false, subagentToolkit: 'full' } }); + assert.strictEqual(r.level, 'absent'); + }); + + test('dispatch: maxDepth===0 → absent', () => { + const r = degradationFor('dispatch', { dispatch: { namedDispatch: true, nested: true, maxDepth: 0, background: true, subagentToolkit: 'full' } }); + assert.strictEqual(r.level, 'absent'); + }); + + test('dispatch: unbounded (-1) nested → full', () => { + const r = degradationFor('dispatch', { dispatch: { namedDispatch: true, nested: true, maxDepth: -1, background: true, subagentToolkit: 'full' } }); + assert.strictEqual(r.level, 'full'); + }); + + test('dispatch: nested maxDepth>=2 → full', () => { + const r = degradationFor('dispatch', { dispatch: { namedDispatch: true, nested: true, maxDepth: 2, background: true, subagentToolkit: 'full' } }); + assert.strictEqual(r.level, 'full'); + }); + + test('dispatch: full but subagentToolkit read-only → degraded', () => { + const r = degradationFor('dispatch', { dispatch: { namedDispatch: true, nested: true, maxDepth: -1, background: true, subagentToolkit: 'read-only' } }); + assert.strictEqual(r.level, 'degraded'); + }); + + test('dispatch: flat (maxDepth===1) → degraded', () => { + const r = degradationFor('dispatch', { dispatch: { namedDispatch: true, nested: false, maxDepth: 1, background: false, subagentToolkit: 'full' } }); + assert.strictEqual(r.level, 'degraded'); + }); +}); + +// --------------------------------------------------------------------------- +// degradationFor — EVERY enum value returns a defined result with valid level +// --------------------------------------------------------------------------- + +describe('degradationFor — all enum values return valid level', () => { + const VALID_LEVELS = new Set(['full', 'degraded', 'absent']); + + test('command — all commandSurface values', () => { + for (const v of HOST_INTEGRATION_AXES.commandSurface) { + const r = degradationFor('command', { commandSurface: v }); + assert.ok(VALID_LEVELS.has(r.level), `command/${v}: level '${r.level}' invalid`); + assert.strictEqual(typeof r.fallback, 'string'); + } + }); + + test('model — all modelMode values', () => { + for (const v of HOST_INTEGRATION_AXES.modelMode) { + const r = degradationFor('model', { modelMode: v }); + assert.ok(VALID_LEVELS.has(r.level), `model/${v}: level '${r.level}' invalid`); + } + }); + + test('hooks — all hookBus values', () => { + for (const v of HOST_INTEGRATION_AXES.hookBus) { + const r = degradationFor('hooks', { hookBus: v }); + assert.ok(VALID_LEVELS.has(r.level), `hooks/${v}: level '${r.level}' invalid`); + } + }); + + test('state — all stateIO values', () => { + for (const v of HOST_INTEGRATION_AXES.stateIO) { + const r = degradationFor('state', { stateIO: v }); + assert.ok(VALID_LEVELS.has(r.level), `state/${v}: level '${r.level}' invalid`); + } + }); + + test('artifact — all commandSurface values', () => { + for (const v of HOST_INTEGRATION_AXES.commandSurface) { + const r = degradationFor('artifact', { commandSurface: v }); + assert.ok(VALID_LEVELS.has(r.level), `artifact/${v}: level '${r.level}' invalid`); + } + }); +}); + +// --------------------------------------------------------------------------- +// degradationFor — unknown / missing axis → absent + unknown:true, never throws +// --------------------------------------------------------------------------- + +describe('degradationFor — unknown / missing axis', () => { + test('unknown commandSurface value for command → absent + unknown:true', () => { + const r = degradationFor('command', { commandSurface: 'zzz' }); + assert.strictEqual(r.level, 'absent'); + assert.strictEqual(r.unknown, true); + }); + + test('missing commandSurface for command → absent + unknown:true', () => { + const r = degradationFor('command', {}); + assert.strictEqual(r.level, 'absent'); + assert.strictEqual(r.unknown, true); + }); + + test('unknown modelMode → absent + unknown:true', () => { + const r = degradationFor('model', { modelMode: 'zzz' }); + assert.strictEqual(r.level, 'absent'); + assert.strictEqual(r.unknown, true); + }); + + test('missing hookBus for hooks → absent + unknown:true', () => { + const r = degradationFor('hooks', {}); + assert.strictEqual(r.level, 'absent'); + assert.strictEqual(r.unknown, true); + }); + + test('no throw on unknown axis value', () => { + assert.doesNotThrow(() => degradationFor('dispatch', { dispatch: 'not-an-object' })); + }); + + test('no throw on completely empty axes', () => { + for (const point of INTERFACE_POINTS) { + assert.doesNotThrow(() => degradationFor(point, {})); + } + }); +}); + +// --------------------------------------------------------------------------- +// profileOf +// --------------------------------------------------------------------------- + +describe('profileOf', () => { + test('profileOf(PROFILE_BASELINES["programmatic-cli"]) === "programmatic-cli"', () => { + assert.strictEqual(profileOf(PROFILE_BASELINES['programmatic-cli']), 'programmatic-cli'); + }); + + test('profileOf(PROFILE_BASELINES["declarative-cli"]) === "declarative-cli"', () => { + assert.strictEqual(profileOf(PROFILE_BASELINES['declarative-cli']), 'declarative-cli'); + }); + + test('profileOf(PROFILE_BASELINES["ide"]) === "ide"', () => { + assert.strictEqual(profileOf(PROFILE_BASELINES['ide']), 'ide'); + }); + + test('imperative + sandboxed-web → ide', () => { + assert.strictEqual( + profileOf({ embeddingMode: 'imperative', runtime: 'sandboxed-web' }), + 'ide', + ); + }); + + test('imperative + node → programmatic-cli', () => { + assert.strictEqual( + profileOf({ embeddingMode: 'imperative', runtime: 'node' }), + 'programmatic-cli', + ); + }); + + test('declarative → declarative-cli', () => { + assert.strictEqual( + profileOf({ embeddingMode: 'declarative' }), + 'declarative-cli', + ); + }); + + test('empty axes → null', () => { + assert.strictEqual(profileOf({}), null); + }); + + test('PROFILE_BASELINES are frozen', () => { + assert.ok(Object.isFrozen(PROFILE_BASELINES), 'PROFILE_BASELINES must be frozen'); + }); +}); + +// --------------------------------------------------------------------------- +// negotiateHostCapabilities — HAPPY PATH +// --------------------------------------------------------------------------- + +describe('negotiateHostCapabilities — happy path', () => { + test('declarative-cli baseline → effective matches, no warnings, points.command.effectiveLevel===full', () => { + const baseline = PROFILE_BASELINES['declarative-cli']; + const result = negotiateHostCapabilities(baseline); + + // No warnings + assert.deepStrictEqual(result.warnings, [], 'Expected no warnings for full declarative-cli baseline'); + + // Key points + assert.strictEqual(result.points.command.effectiveLevel, 'full'); + assert.strictEqual(result.points.hooks.effectiveLevel, 'full'); + assert.strictEqual(result.points.state.effectiveLevel, 'full'); + + // protocolVersion + assert.strictEqual(result.protocolVersion, PROTOCOL_VERSION); + + // effective axes match baseline (scalar) + assert.strictEqual(result.effective.embeddingMode, baseline.embeddingMode); + assert.strictEqual(result.effective.commandSurface, baseline.commandSurface); + assert.strictEqual(result.effective.modelMode, baseline.modelMode); + assert.strictEqual(result.effective.hookBus, baseline.hookBus); + assert.strictEqual(result.effective.stateIO, baseline.stateIO); + + // effective dispatch has maxDepth resolved (declarative has maxDepth:1) + assert.strictEqual(result.effective.dispatch.maxDepth, 1); + assert.strictEqual(result.effective.dispatch.namedDispatch, true); + }); + + test('all INTERFACE_POINTS are present in result.points', () => { + const result = negotiateHostCapabilities(PROFILE_BASELINES['programmatic-cli']); + for (const point of INTERFACE_POINTS) { + assert.ok(point in result.points, `Missing point: ${point}`); + assert.ok(['full', 'degraded', 'absent'].includes(result.points[point].effectiveLevel), + `Invalid effectiveLevel for ${point}`); + } + }); +}); + +// --------------------------------------------------------------------------- +// negotiateHostCapabilities — SECURITY / HOSTILE +// --------------------------------------------------------------------------- + +describe('negotiateHostCapabilities — security / hostile', () => { + test('(1) host declares future commandSurface at protocolVersion 99 → effective is KNOWN value, NOT the unknown one', () => { + const result = negotiateHostCapabilities({ + ...PROFILE_BASELINES['programmatic-cli'], + commandSurface: 'future-surface', + protocolVersion: 99, + }); + // effective.commandSurface must be a KNOWN value + assert.ok( + HOST_INTEGRATION_AXES.commandSurface.includes(result.effective.commandSurface), + `effective.commandSurface '${result.effective.commandSurface}' is not in known vocabulary`, + ); + assert.notStrictEqual(result.effective.commandSurface, 'future-surface', + 'future-surface must NOT appear in effective'); + // A warning mentioning protocolVersion + const warnText = result.warnings.join(' '); + assert.ok(warnText.includes('protocolVersion') || warnText.includes('unknown'), + `Expected a warning about protocolVersion or unknown value; got: ${warnText}`); + }); + + test('(2) host modelMode active but engine passive → effective.modelMode === passive', () => { + const restrictedEngine = { + ...DEFAULT_ENGINE, + axes: { ...DEFAULT_ENGINE.axes, modelMode: 'passive' }, + }; + const result = negotiateHostCapabilities( + { ...PROFILE_BASELINES['programmatic-cli'], modelMode: 'active' }, + restrictedEngine, + ); + assert.strictEqual(result.effective.modelMode, 'passive'); + }); + + test('(3) host dispatch maxDepth:5 nested:true but engine dispatch maxDepth:1 → effective.dispatch.maxDepth===1', () => { + const restrictedEngine = { + ...DEFAULT_ENGINE, + axes: { + ...DEFAULT_ENGINE.axes, + dispatch: { ...DEFAULT_ENGINE.axes.dispatch, maxDepth: 1, nested: false }, + }, + }; + const result = negotiateHostCapabilities( + { + ...PROFILE_BASELINES['programmatic-cli'], + dispatch: { namedDispatch: true, nested: true, maxDepth: 5, background: true, subagentToolkit: 'full' }, + }, + restrictedEngine, + ); + assert.strictEqual(result.effective.dispatch.maxDepth, 1); + }); + + test('(4) host omits hookBus → effective.hookBus is safe default + warning present', () => { + const hostWithoutHookBus = { ...PROFILE_BASELINES['declarative-cli'] }; + delete hostWithoutHookBus.hookBus; + + const result = negotiateHostCapabilities(hostWithoutHookBus); + // effective hookBus must be a known value + assert.ok( + HOST_INTEGRATION_AXES.hookBus.includes(result.effective.hookBus), + `effective.hookBus '${result.effective.hookBus}' is not known`, + ); + // points.hooks must be present + assert.ok('hooks' in result.points, 'points.hooks must be present'); + // a warning mentioning hookBus + const warnText = result.warnings.join(' '); + assert.ok(warnText.includes('hookBus'), `Expected warning about hookBus; got: ${warnText}`); + }); + + test('(5) INVARIANT: every effective scalar ∈ engine.known[axis] for hostile hosts', () => { + const hostileHosts = [ + // All unknown values + { + embeddingMode: 'future-mode', + commandSurface: 'future-surface', + modelMode: 'quantum', + hookBus: 'blockchain', + stateIO: 'cloud-magic', + transport: 'telepathy', + runtime: 'wasm', + protocolVersion: 999, + }, + // Mix of known and unknown + { + embeddingMode: 'imperative', + commandSurface: 'palette', + modelMode: 'active', + hookBus: 'none', + stateIO: 'unknown-future', + transport: 'mcp', + runtime: 'sandboxed-web', + }, + // Empty host + {}, + // Only dispatch with extreme values + { + dispatch: { namedDispatch: true, nested: true, maxDepth: 9999, background: true, subagentToolkit: 'full' }, + }, + ]; + + const scalarAxes = ['embeddingMode', 'commandSurface', 'modelMode', 'hookBus', 'stateIO', 'transport', 'runtime']; + + for (const host of hostileHosts) { + const result = negotiateHostCapabilities(host); + for (const axis of scalarAxes) { + const effectiveVal = result.effective[axis]; + assert.ok( + HOST_INTEGRATION_AXES[axis].includes(effectiveVal), + `INVARIANT VIOLATION: effective.${axis}='${effectiveVal}' is NOT in known vocabulary for host=${JSON.stringify(host)}`, + ); + } + } + }); + + test('host protocolVersion > engine → warning mentioning protocolVersion', () => { + const result = negotiateHostCapabilities({ + ...PROFILE_BASELINES['declarative-cli'], + protocolVersion: 99, + }); + const warnText = result.warnings.join(' '); + assert.ok(warnText.includes('protocolVersion'), `Expected protocolVersion warning; got: ${warnText}`); + assert.strictEqual(result.protocolVersion, PROTOCOL_VERSION); + }); +}); + +// --------------------------------------------------------------------------- +// INDEPENDENCE: mutation safety +// --------------------------------------------------------------------------- + +describe('independence / mutation safety', () => { + test('mutating returned result does not affect second call', () => { + const host = PROFILE_BASELINES['declarative-cli']; + const r1 = negotiateHostCapabilities(host); + // Mutate r1 + r1.warnings.push('injected'); + r1.effective.modelMode = 'active'; + r1.points.command.effectiveLevel = 'absent'; + + const r2 = negotiateHostCapabilities(host); + // r2 must not be affected + assert.deepStrictEqual(r2.warnings, [], 'r2.warnings must not include injected warning'); + assert.strictEqual(r2.effective.modelMode, host.modelMode, 'r2.effective.modelMode must be original value'); + assert.strictEqual(r2.points.command.effectiveLevel, 'full', 'r2.points.command.effectiveLevel must be full'); + }); + + test('all exports are present on the module', () => { + const expectedExports = [ + 'PROTOCOL_VERSION', 'HOST_INTEGRATION_AXES', 'INTERFACE_POINTS', + 'PROFILE_BASELINES', 'DEFAULT_ENGINE', 'UNDOCUMENTED', + 'degradationFor', 'profileOf', 'negotiateHostCapabilities', + ]; + for (const exp of expectedExports) { + assert.ok(exp in hi, `Missing export: ${exp}`); + } + }); +}); + +// --------------------------------------------------------------------------- +// Decision 1: undocumented sentinel — fail-closed in negotiation +// --------------------------------------------------------------------------- + +describe('Decision 1: UNDOCUMENTED sentinel — fail-closed negotiation', () => { + test('negotiate with embeddingMode:"undocumented" → effective is safe default (documented value), NOT "undocumented"', () => { + const host = { + ...PROFILE_BASELINES['declarative-cli'], + embeddingMode: 'undocumented', + }; + const result = negotiateHostCapabilities(host); + // effective.embeddingMode must be a documented value, NOT 'undocumented' + assert.ok( + HOST_INTEGRATION_AXES.embeddingMode.includes(result.effective.embeddingMode), + `effective.embeddingMode must be a documented value; got '${result.effective.embeddingMode}'`, + ); + assert.notStrictEqual(result.effective.embeddingMode, 'undocumented', + 'effective.embeddingMode must not be "undocumented"'); + // A warning mentioning "undocumented" + const warnText = result.warnings.join(' '); + assert.ok(warnText.includes('undocumented'), + `Expected a warning mentioning "undocumented"; got: ${warnText}`); + }); + + test('negotiate with dispatch fields all "undocumented" → fail-closed dispatch', () => { + const host = { + ...PROFILE_BASELINES['programmatic-cli'], + dispatch: { + namedDispatch: 'undocumented', + nested: 'undocumented', + maxDepth: 'undocumented', + background: 'undocumented', + subagentToolkit: 'undocumented', + }, + }; + const result = negotiateHostCapabilities(host); + const d = result.effective.dispatch; + assert.strictEqual(d.namedDispatch, false, 'namedDispatch must be false when "undocumented"'); + assert.strictEqual(d.nested, false, 'nested must be false when "undocumented"'); + assert.strictEqual(d.background, false, 'background must be false when "undocumented"'); + assert.strictEqual(d.subagentToolkit, 'read-only', 'subagentToolkit must be "read-only" when "undocumented"'); + assert.strictEqual(d.maxDepth, 0, 'maxDepth must be 0 when "undocumented"'); + // points.dispatch must be absent + assert.strictEqual(result.points.dispatch.effectiveLevel, 'absent', + 'points.dispatch.effectiveLevel must be "absent" when dispatch is all undocumented'); + }); + + test('degradationFor dispatch with namedDispatch:"undocumented" → level "absent"', () => { + const r = degradationFor('dispatch', { + dispatch: { + namedDispatch: 'undocumented', + nested: false, + maxDepth: 0, + background: false, + subagentToolkit: 'full', + }, + }); + assert.strictEqual(r.level, 'absent', + `degradationFor with namedDispatch:"undocumented" must return absent; got "${r.level}"`); + }); + + test('subagentToolkit "undocumented" (truthy string) fails closed to read-only', () => { + const host = { + ...PROFILE_BASELINES['programmatic-cli'], + dispatch: { + namedDispatch: true, + nested: true, + maxDepth: -1, + background: true, + subagentToolkit: 'undocumented', + }, + }; + const result = negotiateHostCapabilities(host); + assert.strictEqual(result.effective.dispatch.subagentToolkit, 'read-only', + 'subagentToolkit "undocumented" must degrade to "read-only"'); + }); +}); + +// --------------------------------------------------------------------------- +// Decision 2: expanded runtime vocabulary (8 documented values) +// --------------------------------------------------------------------------- + +describe('Decision 2: expanded runtime vocabulary', () => { + const newRuntimes = ['python', 'go', 'rust', 'electron', 'other']; + + for (const rt of newRuntimes) { + test(`negotiate with runtime:"${rt}" → effective.runtime === "${rt}" (no warn about unknown)`, () => { + const host = { + ...PROFILE_BASELINES['programmatic-cli'], + runtime: rt, + }; + const result = negotiateHostCapabilities(host); + assert.strictEqual(result.effective.runtime, rt, + `effective.runtime must be "${rt}"; got "${result.effective.runtime}"`); + // Must NOT have an unknown-value warning for this runtime + const runtimeWarnings = result.warnings.filter((w) => w.includes('runtime') && w.includes('not trusted')); + assert.strictEqual(runtimeWarnings.length, 0, + `Must not warn about unknown runtime "${rt}"; warnings: ${result.warnings.join(', ')}`); + }); + } + + test('runtime "undocumented" (sentinel) → fail-closed to safe default', () => { + const host = { + ...PROFILE_BASELINES['programmatic-cli'], + runtime: 'undocumented', + }; + const result = negotiateHostCapabilities(host); + // Must be a documented value, not "undocumented" + assert.ok( + HOST_INTEGRATION_AXES.runtime.includes(result.effective.runtime), + `effective.runtime must be documented; got "${result.effective.runtime}"`, + ); + assert.notStrictEqual(result.effective.runtime, 'undocumented'); + const warnText = result.warnings.join(' '); + assert.ok(warnText.includes('undocumented'), `Expected undocumented warning; got: ${warnText}`); + }); + + test('"wasm" (genuinely unknown, not sentinel) → still fails closed with "not trusted" warning', () => { + const host = { + ...PROFILE_BASELINES['programmatic-cli'], + runtime: 'wasm', + }; + const result = negotiateHostCapabilities(host); + assert.ok(HOST_INTEGRATION_AXES.runtime.includes(result.effective.runtime), + `effective.runtime must be documented; got "${result.effective.runtime}"`); + const warnText = result.warnings.join(' '); + assert.ok(warnText.includes('not trusted') || warnText.includes('unknown'), + `Expected not-trusted/unknown warning; got: ${warnText}`); + }); +}); + +// --------------------------------------------------------------------------- +// Fix 1: degradationFor('dispatch') fail-closed on non-'full' subagentToolkit +// --------------------------------------------------------------------------- + +describe('Fix 1: degradationFor dispatch fails closed on non-full subagentToolkit', () => { + const FULL_DEPTH_DISPATCH = { namedDispatch: true, nested: true, maxDepth: -1, background: true }; + + test('subagentToolkit:"full" + full depth → level "full"', () => { + const r = degradationFor('dispatch', { dispatch: { ...FULL_DEPTH_DISPATCH, subagentToolkit: 'full' } }); + assert.strictEqual(r.level, 'full', + 'subagentToolkit:"full" with full depth must return level "full"'); + }); + + test('subagentToolkit:"read-only" + full depth → level "degraded"', () => { + const r = degradationFor('dispatch', { dispatch: { ...FULL_DEPTH_DISPATCH, subagentToolkit: 'read-only' } }); + assert.strictEqual(r.level, 'degraded', + 'subagentToolkit:"read-only" must return level "degraded"'); + assert.ok(r.fallback.length > 0, 'fallback must be non-empty'); + }); + + test('subagentToolkit:"undocumented" + full depth → level "degraded" (fail-closed)', () => { + const r = degradationFor('dispatch', { dispatch: { ...FULL_DEPTH_DISPATCH, subagentToolkit: 'undocumented' } }); + assert.strictEqual(r.level, 'degraded', + 'subagentToolkit:"undocumented" must fail closed to level "degraded"; got "' + r.level + '"'); + assert.ok(r.fallback.length > 0, 'fallback must be non-empty'); + }); + + test('subagentToolkit:"future-xyz" + full depth → level "degraded" (fail-closed)', () => { + const r = degradationFor('dispatch', { dispatch: { ...FULL_DEPTH_DISPATCH, subagentToolkit: 'future-xyz' } }); + assert.strictEqual(r.level, 'degraded', + 'subagentToolkit:"future-xyz" (unknown) must fail closed to level "degraded"; got "' + r.level + '"'); + assert.ok(r.fallback.length > 0, 'fallback must be non-empty'); + }); + + test('subagentToolkit:"" (empty string) + full depth → level "degraded" (fail-closed)', () => { + const r = degradationFor('dispatch', { dispatch: { ...FULL_DEPTH_DISPATCH, subagentToolkit: '' } }); + assert.strictEqual(r.level, 'degraded', + 'subagentToolkit:"" must fail closed to level "degraded"; got "' + r.level + '"'); + }); +}); + +// --------------------------------------------------------------------------- +// New fixes: M1 maxDepth NaN, M2 struct consistency, L1 SAFE_DEFAULTS, +// L2 protocolVersion warn, N1 undocumented dispatch warnings +// --------------------------------------------------------------------------- + +describe('Fix M1: maxDepth NaN bypasses number guard', () => { + test('negotiate with dispatch.maxDepth NaN → effective.dispatch.maxDepth === 0 AND warning about maxDepth AND Number.isFinite', () => { + const result = negotiateHostCapabilities({ + dispatch: { namedDispatch: true, nested: false, maxDepth: NaN, background: false, subagentToolkit: 'full' }, + }); + const d = result.effective.dispatch; + assert.strictEqual(d.maxDepth, 0, 'NaN maxDepth must be normalized to 0'); + assert.ok(Number.isFinite(d.maxDepth), 'effective.dispatch.maxDepth must be finite (Number.isFinite)'); + const warnText = result.warnings.join(' '); + assert.ok(warnText.includes('maxDepth'), `Expected a warning about maxDepth; got: ${warnText}`); + }); + + test('degradationFor dispatch with maxDepth NaN → level "degraded" (not NaN-dependent, not "full")', () => { + const r = degradationFor('dispatch', { + dispatch: { namedDispatch: true, nested: true, maxDepth: NaN, subagentToolkit: 'full' }, + }); + // After fix: depth=(NaN not finite)→0; NaN===0 is false so initial check doesn't fire; + // isUnbounded=false; isFullDepth = false || (nested:true && 0>=2) = false → 'degraded' (flat) + assert.strictEqual(r.level, 'degraded', + `NaN maxDepth with nested:true must yield 'degraded' (depth=0, not full-depth); got: ${r.level}`); + assert.notStrictEqual(r.level, 'full', 'NaN maxDepth must NOT yield "full"'); + }); +}); + +describe('Fix M2: cap nested/background when namedDispatch is false', () => { + test('negotiate with namedDispatch:"undocumented" → namedDispatch false, nested false, background false, maxDepth 0; warnings include namedDispatch undocumented note', () => { + const result = negotiateHostCapabilities({ + dispatch: { namedDispatch: 'undocumented', nested: true, background: true, maxDepth: 5, subagentToolkit: 'full' }, + }); + const d = result.effective.dispatch; + assert.strictEqual(d.namedDispatch, false, 'namedDispatch must be false'); + assert.strictEqual(d.nested, false, 'nested must be false when namedDispatch is false'); + assert.strictEqual(d.background, false, 'background must be false when namedDispatch is false'); + assert.strictEqual(d.maxDepth, 0, 'maxDepth must be 0 when namedDispatch is false'); + const warnText = result.warnings.join(' '); + assert.ok(warnText.includes('namedDispatch') || warnText.includes('dispatch.namedDispatch'), + `Expected a warning about namedDispatch being undocumented; got: ${warnText}`); + }); +}); + +describe('Fix L1: SAFE_DEFAULTS.dispatch.subagentToolkit is read-only', () => { + // CONTRACT: negotiate({}) uses SAFE_DEFAULTS for each axis; dispatch uses its floor + test('negotiate({}) → effective axes match documented SAFE_DEFAULTS (CONTRACT)', () => { + const result = negotiateHostCapabilities({}); + const eff = result.effective; + assert.strictEqual(eff.embeddingMode, 'declarative'); + assert.strictEqual(eff.commandSurface, 'prose-only'); + assert.strictEqual(eff.modelMode, 'passive'); + assert.strictEqual(eff.hookBus, 'none'); + assert.strictEqual(eff.stateIO, 'session-log-append'); + assert.strictEqual(eff.transport, 'mcp'); + assert.strictEqual(eff.runtime, 'node'); + assert.strictEqual(eff.dispatch.subagentToolkit, 'read-only', + 'SAFE_DEFAULTS dispatch floor must be read-only'); + }); +}); + +describe('Fix L2: warn on present-but-non-number protocolVersion', () => { + test('negotiate with protocolVersion:"beta" → warnings include protocolVersion note; result.protocolVersion === engine default (1)', () => { + const result = negotiateHostCapabilities({ + embeddingMode: 'declarative', + commandSurface: 'slash-file', + modelMode: 'passive', + hookBus: 'host', + stateIO: 'filesystem', + transport: 'mcp', + runtime: 'node', + dispatch: { namedDispatch: true, nested: false, maxDepth: 1, background: false, subagentToolkit: 'full' }, + protocolVersion: 'beta', + }); + const warnText = result.warnings.join(' '); + assert.ok(warnText.includes('protocolVersion'), + `Expected a warning about protocolVersion being non-finite/non-number; got: ${warnText}`); + assert.strictEqual(result.protocolVersion, 1, + 'result.protocolVersion must fall back to engine default (1)'); + }); +}); + +describe('Fix N1: symmetric observability warnings for undocumented dispatch fields', () => { + test('dispatch.subagentToolkit:"undocumented" → warning includes "dispatch.subagentToolkit is undocumented"', () => { + const result = negotiateHostCapabilities({ + dispatch: { namedDispatch: true, nested: true, maxDepth: -1, background: true, subagentToolkit: 'undocumented' }, + }); + const warnText = result.warnings.join(' '); + assert.ok(warnText.includes('subagentToolkit') && warnText.includes('undocumented'), + `Expected warning about dispatch.subagentToolkit undocumented; got: ${warnText}`); + }); +}); + +describe('Fix: degradationFor unknown point → {level:"absent", unknown:true}', () => { + test('degradationFor("totally-unknown-point", {}) → {level:"absent", unknown:true}', () => { + const r = degradationFor('totally-unknown-point', {}); + assert.strictEqual(r.level, 'absent', 'unknown point must return absent'); + assert.strictEqual(r.unknown, true, 'unknown point must have unknown:true'); + }); +}); + +// --------------------------------------------------------------------------- +// Phase B: shouldFlattenDispatch — ADR-1239 Phase B / #1708 +// --------------------------------------------------------------------------- + +describe('Phase B: shouldFlattenDispatch — contract pin', () => { + const { shouldFlattenDispatch } = hi; + + test('shouldFlattenDispatch is exported as a function', () => { + assert.strictEqual(typeof shouldFlattenDispatch, 'function', + 'shouldFlattenDispatch must be exported from host-integration module'); + }); + + test('{background:true, backgroundDispatch:true} → false (background OK)', () => { + assert.strictEqual(shouldFlattenDispatch({ background: true, backgroundDispatch: true }), false, + 'canBackground=true when both background===true AND backgroundDispatch===true → flatten=false'); + }); + + test('{background:true, backgroundDispatch:false} → true (must flatten)', () => { + assert.strictEqual(shouldFlattenDispatch({ background: true, backgroundDispatch: false }), true, + 'backgroundDispatch===false → canBackground=false → flatten=true'); + }); + + test('{background:true, backgroundDispatch:"undocumented"} → true (undocumented is not === true)', () => { + assert.strictEqual(shouldFlattenDispatch({ background: true, backgroundDispatch: 'undocumented' }), true, + '"undocumented" is not === true → canBackground=false → flatten=true'); + }); + + test('{background:false, backgroundDispatch:true} → true (background is false)', () => { + assert.strictEqual(shouldFlattenDispatch({ background: false, backgroundDispatch: true }), true, + 'background===false → canBackground=false → flatten=true'); + }); + + test('{} (empty) → true (missing fields → fail-closed)', () => { + assert.strictEqual(shouldFlattenDispatch({}), true, + 'empty dispatch → canBackground=false → flatten=true'); + }); + + test('missing fields individually', () => { + assert.strictEqual(shouldFlattenDispatch({ background: true }), true, + 'backgroundDispatch missing → not === true → flatten=true'); + assert.strictEqual(shouldFlattenDispatch({ backgroundDispatch: true }), true, + 'background missing → not === true → flatten=true'); + }); + + // M1: null-safety — null/undefined/non-object dispatch must fail-closed (not throw) + test('null dispatch → true (fail-closed, no throw)', () => { + assert.strictEqual(shouldFlattenDispatch(null), true, + 'null dispatch must fail-closed to true'); + }); + + test('undefined dispatch → true (fail-closed, no throw)', () => { + assert.strictEqual(shouldFlattenDispatch(undefined), true, + 'undefined dispatch must fail-closed to true'); + }); + + test('string dispatch → true (fail-closed, no throw)', () => { + assert.strictEqual(shouldFlattenDispatch('x'), true, + 'non-object dispatch (string) must fail-closed to true'); + }); + + // #853 codex-like profile: full dispatch including backgroundDispatch:true → background OK + test('#853 codex-like: {namedDispatch:true,nested:true,maxDepth:1,background:true,subagentToolkit:"full",backgroundDispatch:true} → false (background OK)', () => { + assert.strictEqual( + shouldFlattenDispatch({ namedDispatch: true, nested: true, maxDepth: 1, background: true, subagentToolkit: 'full', backgroundDispatch: true }), + false, + 'codex-like dispatch with backgroundDispatch:true must be background-OK (flatten=false)', + ); + }); + + // #853 claude-like profile: backgroundDispatch:false → must flatten + test('#853 claude-like: {...,background:true,backgroundDispatch:false} → true (inline)', () => { + assert.strictEqual( + shouldFlattenDispatch({ namedDispatch: true, nested: true, maxDepth: -1, background: true, subagentToolkit: 'full', backgroundDispatch: false }), + true, + 'claude-like dispatch with backgroundDispatch:false must flatten inline', + ); + }); +}); + +// --------------------------------------------------------------------------- +// Phase B: negotiateHostCapabilities — backgroundDispatch field +// --------------------------------------------------------------------------- + +describe('Phase B: negotiateHostCapabilities — backgroundDispatch', () => { + test('host dispatch.backgroundDispatch:true against DEFAULT_ENGINE → effective.dispatch.backgroundDispatch===true', () => { + const result = negotiateHostCapabilities({ + ...PROFILE_BASELINES['programmatic-cli'], + dispatch: { namedDispatch: true, nested: true, maxDepth: -1, background: true, subagentToolkit: 'full', backgroundDispatch: true }, + }); + assert.strictEqual(result.effective.dispatch.backgroundDispatch, true, + 'backgroundDispatch:true on host AND engine must yield effective backgroundDispatch===true'); + }); + + test('host dispatch.backgroundDispatch:"undocumented" → effective.dispatch.backgroundDispatch===false + warning', () => { + const result = negotiateHostCapabilities({ + ...PROFILE_BASELINES['programmatic-cli'], + dispatch: { namedDispatch: true, nested: true, maxDepth: -1, background: true, subagentToolkit: 'full', backgroundDispatch: 'undocumented' }, + }); + assert.strictEqual(result.effective.dispatch.backgroundDispatch, false, + '"undocumented" must fail-closed to false'); + const warnText = result.warnings.join(' '); + assert.ok(warnText.includes('backgroundDispatch') && warnText.includes('undocumented'), + `Expected warning about backgroundDispatch being undocumented; got: ${warnText}`); + }); + + test('host dispatch.backgroundDispatch:false → effective.dispatch.backgroundDispatch===false', () => { + const result = negotiateHostCapabilities({ + ...PROFILE_BASELINES['programmatic-cli'], + dispatch: { namedDispatch: true, nested: true, maxDepth: -1, background: true, subagentToolkit: 'full', backgroundDispatch: false }, + }); + assert.strictEqual(result.effective.dispatch.backgroundDispatch, false); + }); + + test('host dispatch without backgroundDispatch key → effective.dispatch.backgroundDispatch===false (fail-closed)', () => { + const result = negotiateHostCapabilities({ + ...PROFILE_BASELINES['programmatic-cli'], + dispatch: { namedDispatch: true, nested: true, maxDepth: -1, background: true, subagentToolkit: 'full' }, + }); + assert.strictEqual(result.effective.dispatch.backgroundDispatch, false, + 'Missing backgroundDispatch key must fail-closed to false'); + }); + + test('negotiateHostCapabilities({}) → effective.dispatch.backgroundDispatch===false', () => { + const result = negotiateHostCapabilities({}); + assert.strictEqual(result.effective.dispatch.backgroundDispatch, false, + 'Empty host must produce backgroundDispatch===false (SAFE_DEFAULTS)'); + }); + + test('SAFE_DEFAULTS.dispatch.backgroundDispatch is false', () => { + // Verified via negotiation with empty host + const result = negotiateHostCapabilities({}); + assert.strictEqual(result.effective.dispatch.backgroundDispatch, false); + }); + + test('DEFAULT_ENGINE.axes.dispatch.backgroundDispatch is true', () => { + assert.strictEqual(DEFAULT_ENGINE.axes.dispatch.backgroundDispatch, true, + 'DEFAULT_ENGINE (full engine) must declare backgroundDispatch:true'); + }); + + test('existing dispatch tests still pass — effective.dispatch.namedDispatch present alongside backgroundDispatch', () => { + const result = negotiateHostCapabilities(PROFILE_BASELINES['programmatic-cli']); + const d = result.effective.dispatch; + assert.ok('namedDispatch' in d, 'namedDispatch must still be present'); + assert.ok('backgroundDispatch' in d, 'backgroundDispatch must be present'); + assert.ok('nested' in d && 'maxDepth' in d && 'background' in d && 'subagentToolkit' in d, + 'all original dispatch fields must still be present'); + }); +}); + +// --------------------------------------------------------------------------- +// Fix 2: negotiateHostCapabilities — host omitting 'dispatch' → subagentToolkit 'read-only' +// --------------------------------------------------------------------------- + +describe('Fix 2: negotiate — host omits dispatch → subagentToolkit read-only (fail-closed)', () => { + test('negotiateHostCapabilities({}) → effective.dispatch.subagentToolkit === "read-only"', () => { + const result = negotiateHostCapabilities({}); + assert.strictEqual(result.effective.dispatch.subagentToolkit, 'read-only', + 'When host omits dispatch, subagentToolkit must fail-closed to "read-only"; got "' + result.effective.dispatch.subagentToolkit + '"'); + }); + + test('negotiateHostCapabilities({}) → effective.dispatch.namedDispatch===false, maxDepth===0, nested===false, background===false', () => { + const result = negotiateHostCapabilities({}); + const d = result.effective.dispatch; + assert.strictEqual(d.namedDispatch, false); + assert.strictEqual(d.maxDepth, 0); + assert.strictEqual(d.nested, false); + assert.strictEqual(d.background, false); + }); + + test('negotiateHostCapabilities({}) → points.dispatch.effectiveLevel === "absent"', () => { + const result = negotiateHostCapabilities({}); + assert.strictEqual(result.points.dispatch.effectiveLevel, 'absent', + 'dispatch absent when host omits it'); + }); + + test('host with all axes but no dispatch → subagentToolkit "read-only"', () => { + const hostWithoutDispatch = { + embeddingMode: 'imperative', + commandSurface: 'slash-file', + modelMode: 'passive', + hookBus: 'host', + stateIO: 'filesystem', + transport: 'mcp', + runtime: 'node', + // no dispatch key + }; + const result = negotiateHostCapabilities(hostWithoutDispatch); + assert.strictEqual(result.effective.dispatch.subagentToolkit, 'read-only', + 'Host missing dispatch must produce subagentToolkit "read-only"; got "' + result.effective.dispatch.subagentToolkit + '"'); + }); +}); diff --git a/tests/init.test.cjs b/tests/init.test.cjs index bd9613c51..f0c1ad693 100644 --- a/tests/init.test.cjs +++ b/tests/init.test.cjs @@ -1406,7 +1406,7 @@ describe('cmdInitMapCodebase', () => { path.join(__dirname, '..', 'gsd-core', 'workflows', 'map-codebase.md'), 'utf8' ); // OpenCode must NOT appear in the "WITHOUT Task tool" / "NOT available" condition - const withoutLine = workflow.split('\n').find(l => + const withoutLine = workflow.split(/\r?\n/).find(l => l.includes('NOT available') || l.includes('WITHOUT Task tool') ); assert.ok(withoutLine, 'workflow should have a line about Task tool NOT being available'); diff --git a/tests/install-nested-layout.test.cjs b/tests/install-nested-layout.test.cjs index 7e1136632..de6ff5acf 100644 --- a/tests/install-nested-layout.test.cjs +++ b/tests/install-nested-layout.test.cjs @@ -16,7 +16,7 @@ const os = require('node:os'); const { installRuntimeArtifacts, -} = require('../bin/install.js'); +} = require('../gsd-core/bin/lib/install-engine.cjs'); const { cleanup } = require('./helpers.cjs'); diff --git a/tests/install-regressions.test.cjs b/tests/install-regressions.test.cjs index 166981853..a3e2eb9c3 100644 --- a/tests/install-regressions.test.cjs +++ b/tests/install-regressions.test.cjs @@ -37,7 +37,12 @@ try { else process.env.GSD_TEST_MODE = savedTestMode; } -const { install, installRuntimeArtifacts, uninstallRuntimeArtifacts, mergeClaudePermissions, GSD_CLAUDE_ALLOW_PERMISSIONS, GSD_CLAUDE_DENY_PERMISSIONS, rewriteLegacyManagedNodeHookCommands, resolveNodeRunner } = installExports || {}; +const { install, mergeClaudePermissions, GSD_CLAUDE_ALLOW_PERMISSIONS, GSD_CLAUDE_DENY_PERMISSIONS, rewriteLegacyManagedNodeHookCommands, resolveNodeRunner } = installExports || {}; + +const { + installRuntimeArtifacts, + uninstallRuntimeArtifacts, +} = require('../gsd-core/bin/lib/install-engine.cjs'); const INSTALL_SCRIPT = path.join(__dirname, '..', 'bin', 'install.js'); const HOOKS_SRC = path.join(__dirname, '..', 'hooks'); diff --git a/tests/install-runtime-artifacts.test.cjs b/tests/install-runtime-artifacts.test.cjs index 0132978d8..898731f67 100644 --- a/tests/install-runtime-artifacts.test.cjs +++ b/tests/install-runtime-artifacts.test.cjs @@ -29,6 +29,9 @@ const { createTempDir, cleanup } = require('./helpers.cjs'); const { installRuntimeArtifacts, installOpencodeFamilySkills, +} = require('../gsd-core/bin/lib/install-engine.cjs'); + +const { parseRuntimeInput, allRuntimes, } = require('../bin/install.js'); diff --git a/tests/install-write-confinement.test.cjs b/tests/install-write-confinement.test.cjs new file mode 100644 index 000000000..79d84e322 --- /dev/null +++ b/tests/install-write-confinement.test.cjs @@ -0,0 +1,385 @@ +'use strict'; + +/** + * Behavioral regression tests for ADR-1239 Phase B write-confinement. + * + * Tests cover: + * - copyWithPathReplacement: happy path, escape rejection, dest===root, + * fail-closed (no confinementRoot), symlink escape + * - installCodexConfig: happy path, agent name-injection rejection + * - _copyStaged: escape rejection, symlink escape (regression preserved) + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const { cleanup } = require('./helpers.cjs'); + +process.env['GSD_TEST_MODE'] = '1'; +const { + copyWithPathReplacement, + installCodexConfig, + _copyStaged, +} = require('../bin/install.js'); + +// --------------------------------------------------------------------------- +// copyWithPathReplacement +// --------------------------------------------------------------------------- + +describe('copyWithPathReplacement write-confinement', () => { + test('1. happy path: file is written under confinementRoot', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cwpr-happy-')); + try { + const srcDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cwpr-src-')); + try { + fs.writeFileSync(path.join(srcDir, 'test.md'), '---\nname: test\n---\nbody\n', 'utf8'); + const destDir = path.join(root, 'sub', 'dest'); + copyWithPathReplacement(srcDir, destDir, '~/.claude/', 'claude', false, false, root); + // The dest dir and its content must exist inside root + const written = fs.existsSync(path.join(destDir, 'test.md')); + assert.ok(written, 'test.md must have been written to destDir under root'); + assert.ok( + path.resolve(destDir).startsWith(path.resolve(root) + path.sep), + 'destDir must be under root', + ); + } finally { + cleanup(srcDir); + } + } finally { + cleanup(root); + } + }); + + test('2. escape rejected: destDir outside confinementRoot → throws, nothing written at escape path', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cwpr-root-')); + const escapeName = 'gsd-cwpr-escape-' + Date.now(); + const escapePath = path.join(os.tmpdir(), escapeName); + try { + const srcDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cwpr-src2-')); + try { + fs.writeFileSync(path.join(srcDir, 'evil.md'), '# evil\n', 'utf8'); + // destDir resolves outside root via parent traversal + const destDir = path.join(root, '..', escapeName); + assert.throws( + () => copyWithPathReplacement(srcDir, destDir, '~/.claude/', 'claude', false, false, root), + /escap|must be a strict subpath|refusing/i, + ); + // Nothing must have been created at the escape path + assert.ok(!fs.existsSync(escapePath), 'must not create anything at the escape path'); + } finally { + cleanup(srcDir); + } + } finally { + cleanup(root); + // also remove escapePath if it was somehow created (defensive) + if (fs.existsSync(escapePath)) cleanup(escapePath); + } + }); + + test('3. dest === root rejected: throws when destDir equals confinementRoot', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cwpr-eqroot-')); + try { + const srcDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cwpr-src3-')); + try { + fs.writeFileSync(path.join(srcDir, 'x.md'), '# x\n', 'utf8'); + assert.throws( + () => copyWithPathReplacement(srcDir, root, '~/.claude/', 'claude', false, false, root), + /escap|must be a strict subpath|refusing|configHome itself/i, + ); + } finally { + cleanup(srcDir); + } + } finally { + cleanup(root); + } + }); + + test('4. fail-closed: omitting confinementRoot throws with descriptive message', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cwpr-fc-')); + try { + const srcDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cwpr-src4-')); + try { + fs.writeFileSync(path.join(srcDir, 'y.md'), '# y\n', 'utf8'); + const destDir = path.join(root, 'sub'); + assert.throws( + () => copyWithPathReplacement(srcDir, destDir, '~/.claude/', 'claude', false, false, undefined), + /confinementRoot is required/, + ); + } finally { + cleanup(srcDir); + } + } finally { + cleanup(root); + } + }); + + test('5. symlink escape: destDir via symlink outside root → throws', (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cwpr-syml-')); + const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cwpr-out-')); + try { + const srcDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cwpr-src5-')); + try { + fs.writeFileSync(path.join(srcDir, 'z.md'), '# z\n', 'utf8'); + const linkPath = path.join(root, 'link'); + try { + fs.symlinkSync(outside, linkPath); + } catch (_symlinkErr) { + // Symlink creation unsupported on this platform/privilege — skip test body + t.skip('symlink creation unsupported on this platform/privilege'); + return; + } + const destDir = path.join(linkPath, 'sub'); + assert.throws( + () => copyWithPathReplacement(srcDir, destDir, '~/.claude/', 'claude', false, false, root), + /symlink|escap|confinement|install root/i, + ); + // Nothing written to outside + assert.strictEqual(fs.readdirSync(outside).length, 0, 'must not write to the outside dir via symlink'); + } finally { + cleanup(srcDir); + } + } finally { + // unlink the symlink before cleanup to avoid crossing boundaries + try { fs.unlinkSync(path.join(root, 'link')); } catch { /* already gone */ } + cleanup(root); + cleanup(outside); + } + }); +}); + +// --------------------------------------------------------------------------- +// installCodexConfig +// --------------------------------------------------------------------------- + +describe('installCodexConfig write-confinement', () => { + test('6. happy path: config.toml and agents/.toml written under targetDir', () => { + const targetDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-icc-happy-')); + try { + const agentsSrc = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-icc-src-')); + try { + // Minimal valid agent frontmatter + fs.writeFileSync( + path.join(agentsSrc, 'gsd-foo.md'), + '---\nname: gsd-foo\ndescription: x\n---\nbody\n', + 'utf8', + ); + const count = installCodexConfig(targetDir, agentsSrc); + assert.strictEqual(count, 1, 'must return count of 1 agent processed'); + assert.ok(fs.existsSync(path.join(targetDir, 'config.toml')), 'config.toml must exist under targetDir'); + assert.ok(fs.existsSync(path.join(targetDir, 'agents', 'gsd-foo.toml')), 'agents/gsd-foo.toml must exist under targetDir'); + } finally { + cleanup(agentsSrc); + } + } finally { + cleanup(targetDir); + } + }); + + test('7a. name-injection rejected: frontmatter name "../../evil" must throw, nothing written at escape', () => { + const targetDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-icc-inj-')); + // Use a unique escape name derived from the targetDir basename so the escape + // path can never collide with pre-existing files in os.tmpdir(). + // agentsTomlDir = resolve(targetDir, 'agents'); ../../.toml from + // there = resolve(targetDir, '../.toml') = dirname(targetDir)/.toml + const escapeName = path.basename(targetDir) + '-escape'; + const escapePath = path.join(path.dirname(targetDir), escapeName + '.toml'); + try { + const agentsSrc = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-icc-src-inj-')); + try { + fs.writeFileSync( + path.join(agentsSrc, 'gsd-evil.md'), + `---\nname: ../../${escapeName}\ndescription: injected\n---\nbody\n`, + 'utf8', + ); + assert.throws( + () => installCodexConfig(targetDir, agentsSrc), + /escap|strict subpath|refusing|NUL/i, + ); + // Verify nothing was written at the escape location + assert.ok(!fs.existsSync(escapePath), 'no file/dir written at escape location'); + } finally { + cleanup(agentsSrc); + } + } finally { + cleanup(targetDir); + if (fs.existsSync(escapePath)) cleanup(escapePath); + } + }); + + test('7b. name-injection: "../config" and "../evil" must both throw (clobber-prevention, tighter agentsTomlDir root)', () => { + // With confinement rooted at agentsTomlDir (not targetDir), a name like + // "../config" resolves to targetDir/config.toml — still inside the configHome + // but OUTSIDE agents/ — so the gate must throw (clobber prevention). + // Similarly "../evil" resolves to targetDir/evil.toml, also outside agents/. + // Both must throw regardless of whether they escape targetDir. + + // Case A: "../config" — would clobber config.toml, must throw. + const targetDirA = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-icc-inj2a-')); + try { + const agentsSrcA = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-icc-src-inj2a-')); + try { + fs.writeFileSync( + path.join(agentsSrcA, 'gsd-clobber.md'), + '---\nname: ../config\ndescription: clobber attempt\n---\nbody\n', + 'utf8', + ); + assert.throws( + () => installCodexConfig(targetDirA, agentsSrcA), + /escap|strict subpath|refusing|NUL/i, + 'name "../config" must throw — it escapes agents/ even though it stays inside targetDir', + ); + } finally { + cleanup(agentsSrcA); + } + } finally { + cleanup(targetDirA); + } + + // Case B: "../evil" — escapes agents/, must throw (new behavior with agentsTomlDir root). + const targetDirB = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-icc-inj2b-')); + try { + const agentsSrcB = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-icc-src-inj2b-')); + try { + fs.writeFileSync( + path.join(agentsSrcB, 'gsd-up.md'), + '---\nname: ../up-escape-attempt\ndescription: boundary test\n---\nbody\n', + 'utf8', + ); + assert.throws( + () => installCodexConfig(targetDirB, agentsSrcB), + /escap|strict subpath|refusing|NUL/i, + 'name "../evil" must throw — it escapes agents/ (resolves to targetDir/evil.toml)', + ); + } finally { + cleanup(agentsSrcB); + } + } finally { + cleanup(targetDirB); + } + + // Case C: "../../evil" still throws (escapes both agents/ and targetDir). + const targetDirC = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-icc-inj2c-')); + try { + const agentsSrcC = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-icc-src-inj2c-')); + try { + fs.writeFileSync( + path.join(agentsSrcC, 'gsd-deep.md'), + '---\nname: ../../deep-escape\ndescription: deep escape\n---\nbody\n', + 'utf8', + ); + assert.throws( + () => installCodexConfig(targetDirC, agentsSrcC), + /escap|strict subpath|refusing|NUL/i, + ); + } finally { + cleanup(agentsSrcC); + } + } finally { + cleanup(targetDirC); + } + }); + + test('10. symlink-escape: agents/ is a symlink outside targetDir → throws', (t) => { + const targetDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-icc-syml-')); + const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-icc-syml-out-')); + try { + const agentsSrc = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-icc-syml-src-')); + try { + fs.writeFileSync( + path.join(agentsSrc, 'gsd-foo.md'), + '---\nname: gsd-foo\ndescription: x\n---\nbody\n', + 'utf8', + ); + const agentsLink = path.join(targetDir, 'agents'); + try { + fs.symlinkSync(outsideDir, agentsLink); + } catch (_symlinkErr) { + // Symlink creation unsupported on this platform/privilege — skip + t.skip('symlink creation unsupported on this platform/privilege'); + return; + } + assert.throws( + () => installCodexConfig(targetDir, agentsSrc), + /symlink|escap|refusing/i, + ); + // Nothing must have been written to the outside dir via the symlink + assert.strictEqual(fs.readdirSync(outsideDir).length, 0, 'must not write to the outside dir via symlink'); + } finally { + cleanup(agentsSrc); + } + } finally { + try { fs.unlinkSync(path.join(targetDir, 'agents')); } catch { /* already gone */ } + cleanup(targetDir); + cleanup(outsideDir); + } + }); +}); + +// --------------------------------------------------------------------------- +// _copyStaged +// --------------------------------------------------------------------------- + +describe('_copyStaged write-confinement', () => { + test('8. escape rejected (regression): destDir escaping configDir → throws', () => { + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cs-cfg-')); + const stagedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cs-staged-')); + const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cs-outside-')); + try { + fs.writeFileSync(path.join(stagedDir, 'help.md'), '# help\n', 'utf8'); + assert.throws( + () => _copyStaged(stagedDir, outsideDir, { kind: 'commands', destSubpath: 'commands', prefix: 'gsd-' }, configDir), + /escap|strict subpath|refusing|configHome/i, + ); + } finally { + cleanup(configDir); + cleanup(stagedDir); + cleanup(outsideDir); + } + }); + + test('9. symlink escape rejected: destDir containing symlink to outside → throws', (t) => { + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cs-syml-')); + const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cs-syml-out-')); + const stagedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cs-staged2-')); + try { + fs.writeFileSync(path.join(stagedDir, 'help.md'), '# help\n', 'utf8'); + const linkPath = path.join(configDir, 'link'); + try { + fs.symlinkSync(outside, linkPath); + } catch (symlinkErr) { + // Symlink creation unsupported on this platform/privilege — skip + t.skip('symlink creation unsupported on this platform/privilege'); + return; + } + const destDir = path.join(linkPath, 'sub'); + assert.throws( + () => _copyStaged(stagedDir, destDir, { kind: 'commands', destSubpath: 'commands/link/sub', prefix: 'gsd-' }, configDir), + /symlink|escap|confinement|install root/i, + ); + assert.strictEqual(fs.readdirSync(outside).length, 0, 'must not have written to outside dir'); + } finally { + try { fs.unlinkSync(path.join(configDir, 'link')); } catch { /* already gone */ } + cleanup(configDir); + cleanup(outside); + cleanup(stagedDir); + } + }); + + test('11. fail-closed: omitting configDir throws with descriptive message', () => { + const stagedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cs-fc-staged-')); + const destDir = path.join(os.tmpdir(), 'gsd-cs-fc-dest-' + Date.now()); + try { + fs.writeFileSync(path.join(stagedDir, 'help.md'), '# help\n', 'utf8'); + assert.throws( + () => _copyStaged(stagedDir, destDir, { kind: 'commands', destSubpath: 'commands', prefix: 'gsd-' }, undefined), + /configDir.*required|required to confine/i, + ); + } finally { + cleanup(stagedDir); + if (fs.existsSync(destDir)) cleanup(destDir); + } + }); +}); diff --git a/tests/install.test.cjs b/tests/install.test.cjs index 2e87d5833..2144a9227 100644 --- a/tests/install.test.cjs +++ b/tests/install.test.cjs @@ -207,7 +207,7 @@ describe('getGlobalConfigDir — explicit configDir overrides env for all runtim const savedHome = process.env.HERMES_HOME; process.env.HERMES_HOME = '~/from-env'; try { - assert.strictEqual(getGlobalConfigDir('hermes', '/explicit/hermes'), '/explicit/hermes'); + assert.strictEqual(String(getGlobalConfigDir('hermes', '/explicit/hermes')).replace(/\\/g, '/'), '/explicit/hermes'); } finally { if (savedHome !== undefined) process.env.HERMES_HOME = savedHome; else delete process.env.HERMES_HOME; @@ -218,7 +218,7 @@ describe('getGlobalConfigDir — explicit configDir overrides env for all runtim const saved = process.env.KILO_CONFIG_DIR; process.env.KILO_CONFIG_DIR = '~/from-env'; try { - assert.strictEqual(getGlobalConfigDir('kilo', '/explicit/kilo'), '/explicit/kilo'); + assert.strictEqual(String(getGlobalConfigDir('kilo', '/explicit/kilo')).replace(/\\/g, '/'), '/explicit/kilo'); } finally { if (saved !== undefined) process.env.KILO_CONFIG_DIR = saved; else delete process.env.KILO_CONFIG_DIR; diff --git a/tests/intel.test.cjs b/tests/intel.test.cjs index 104769332..235441afd 100644 --- a/tests/intel.test.cjs +++ b/tests/intel.test.cjs @@ -1134,7 +1134,7 @@ describe('#1000 regression: gsd-intel-updater emits canonical intel filenames', // Guard against substring false-positives (e.g. 'files.json' inside 'file-roles.json'): // canonical long names never contain these short tokens, verified by the canonical set. const offendingLines = agentPrompt - .split('\n') + .split(/\r?\n/) .filter((line) => line.includes(shortName)); assert.strictEqual( offendingLines.length, diff --git a/tests/inventory-headings-countfree.test.cjs b/tests/inventory-headings-countfree.test.cjs index 53335e567..722bd676f 100644 --- a/tests/inventory-headings-countfree.test.cjs +++ b/tests/inventory-headings-countfree.test.cjs @@ -21,7 +21,7 @@ const INVENTORY_PATH = path.join(ROOT, 'docs', 'INVENTORY.md'); test('docs/INVENTORY.md has no "(N shipped)" count scalars in headings', () => { const content = fs.readFileSync(INVENTORY_PATH, 'utf8'); const offenders = content - .split('\n') + .split(/\r?\n/) .filter((line) => /^##\s+.+\(\d+\s+shipped\)/.test(line)); assert.ok( diff --git a/tests/issue-2517-runtime-aware-profiles.test.cjs b/tests/issue-2517-runtime-aware-profiles.test.cjs index 84b3d43d4..dbca0dcc4 100644 --- a/tests/issue-2517-runtime-aware-profiles.test.cjs +++ b/tests/issue-2517-runtime-aware-profiles.test.cjs @@ -57,17 +57,21 @@ function writeConfig(tmpDir, obj) { // behavior. Capture HOME, point it at an isolated tmpdir for the duration of // each test, restore on teardown. let _origHome; +let _origUserProfile; let _origGsdHome; let _isolatedHome; function isolateHome() { _origHome = process.env.HOME; + _origUserProfile = process.env.USERPROFILE; _origGsdHome = process.env.GSD_HOME; _isolatedHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-home-iso-')); process.env.HOME = _isolatedHome; + process.env.USERPROFILE = _isolatedHome; process.env.GSD_HOME = _isolatedHome; } function restoreHome() { if (_origHome === undefined) delete process.env.HOME; else process.env.HOME = _origHome; + if (_origUserProfile === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = _origUserProfile; if (_origGsdHome === undefined) delete process.env.GSD_HOME; else process.env.GSD_HOME = _origGsdHome; cleanup(_isolatedHome); _isolatedHome = null; diff --git a/tests/issue-69-surface-keeps-nested.test.cjs b/tests/issue-69-surface-keeps-nested.test.cjs index ec10dcb2d..baf123af9 100644 --- a/tests/issue-69-surface-keeps-nested.test.cjs +++ b/tests/issue-69-surface-keeps-nested.test.cjs @@ -26,7 +26,7 @@ const os = require('node:os'); const ROOT = path.join(__dirname, '..'); const COMMANDS_GSD = path.join(ROOT, 'commands', 'gsd'); -const { installRuntimeArtifacts } = require('../bin/install.js'); +const { installRuntimeArtifacts } = require('../gsd-core/bin/lib/install-engine.cjs'); const { applySurface } = require('../gsd-core/bin/lib/surface.cjs'); const { loadSkillsManifest, resolveProfile } = require('../gsd-core/bin/lib/install-profiles.cjs'); const { resolveRuntimeArtifactLayout } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); diff --git a/tests/lint-windows-test-portability.test.cjs b/tests/lint-windows-test-portability.test.cjs deleted file mode 100644 index e3d925a6c..000000000 --- a/tests/lint-windows-test-portability.test.cjs +++ /dev/null @@ -1,136 +0,0 @@ -// windows-portability-ok: fixture strings for the lint's own unit test, not real execution -'use strict'; - -/** - * Tests for scripts/lint-windows-test-portability.cjs - * - * Uses the exported `scanContent` pure function to avoid spawning real - * subprocesses or touching the filesystem. This keeps the test portable and - * prevents the lint from flagging itself (the opt-out comment above covers the - * chmod/bash-c fixture strings below). - */ - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const { scanContent } = require('../scripts/lint-windows-test-portability.cjs'); - -describe('lint-windows-test-portability: scanContent', () => { - test('(a) chmod 0o755 + bash -c with no guard => violation', () => { - const src = ` - 'use strict'; - fs.chmodSync(fixture, 0o755); - execFileSync('bash', ['-c', 'echo hi']); - `; - const result = scanContent(src); - assert.strictEqual(result.makesExecutable, true, 'makesExecutable'); - assert.strictEqual(result.shellDashC, true, 'shellDashC'); - assert.strictEqual(result.guarded, false, 'guarded'); - assert.strictEqual(result.optOut, false, 'optOut'); - assert.strictEqual(result.violation, true, 'violation'); - }); - - test('(b) chmod 0o755 + bash -c + process.platform guard => no violation', () => { - const src = ` - 'use strict'; - fs.chmodSync(fixture, 0o755); - execFileSync('bash', ['-c', 'echo hi']); - if (process.platform !== 'win32') { runIt(); } - `; - const result = scanContent(src); - assert.strictEqual(result.makesExecutable, true, 'makesExecutable'); - assert.strictEqual(result.shellDashC, true, 'shellDashC'); - assert.strictEqual(result.guarded, true, 'guarded'); - assert.strictEqual(result.violation, false, 'violation'); - }); - - test('(c) chmod 0o644 (no exec bit) + bash -c => no violation', () => { - const src = ` - 'use strict'; - fs.chmodSync(fixture, 0o644); - execFileSync('bash', ['-c', 'cat file']); - `; - const result = scanContent(src); - assert.strictEqual(result.makesExecutable, false, 'makesExecutable'); - assert.strictEqual(result.shellDashC, true, 'shellDashC'); - assert.strictEqual(result.violation, false, 'violation'); - }); - - test('(d) chmod 0o755 + execFileSync(sh, [path]) with no -c => no violation', () => { - const src = ` - 'use strict'; - fs.chmodSync(fixture, 0o755); - execFileSync('sh', [fixturePath]); - `; - const result = scanContent(src); - assert.strictEqual(result.makesExecutable, true, 'makesExecutable'); - assert.strictEqual(result.shellDashC, false, 'shellDashC'); - assert.strictEqual(result.violation, false, 'violation'); - }); - - test('(e) violation pattern + windows-portability-ok opt-out => no violation', () => { - const src = ` - // windows-portability-ok: intentional cross-platform test - 'use strict'; - fs.chmodSync(fixture, 0o755); - execFileSync('bash', ['-c', 'run']); - `; - const result = scanContent(src); - assert.strictEqual(result.makesExecutable, true, 'makesExecutable'); - assert.strictEqual(result.shellDashC, true, 'shellDashC'); - assert.strictEqual(result.optOut, true, 'optOut'); - assert.strictEqual(result.violation, false, 'violation'); - }); - - test('chmod 0o111 (pure exec bits) is detected as executable', () => { - const src = `fs.chmodSync(f, 0o111); spawnSync('sh', ['-c', 'x']);`; - const result = scanContent(src); - assert.strictEqual(result.makesExecutable, true, 'makesExecutable'); - assert.strictEqual(result.shellDashC, true, 'shellDashC'); - assert.strictEqual(result.violation, true, 'violation'); - }); - - test('chmod 0o444 (read-only) is not executable', () => { - const src = `fs.chmodSync(f, 0o444); execFileSync('bash', ['-c', 'x']);`; - const result = scanContent(src); - assert.strictEqual(result.makesExecutable, false, 'makesExecutable'); - assert.strictEqual(result.violation, false, 'violation'); - }); - - test('string-literal sh -c form is detected', () => { - const src = ` - fs.chmodSync(f, 0o755); - exec('sh -c "run.sh"'); - `; - const result = scanContent(src); - assert.strictEqual(result.shellDashC, true, 'shellDashC from string literal'); - assert.strictEqual(result.violation, true, 'violation'); - }); - - test('/bin/bash prefix in array form is detected', () => { - const src = ` - fs.chmodSync(f, 0o755); - execFileSync('/bin/bash', ['-c', 'run']); - `; - const result = scanContent(src); - assert.strictEqual(result.shellDashC, true, 'shellDashC with /bin/bash prefix'); - assert.strictEqual(result.violation, true, 'violation'); - }); - - test('isWindows guard suppresses violation', () => { - const src = ` - const isWindows = process.platform === 'win32'; - fs.chmodSync(f, 0o755); - execFileSync('bash', ['-c', 'run']); - `; - const result = scanContent(src); - assert.strictEqual(result.guarded, true, 'guarded via isWindows'); - assert.strictEqual(result.violation, false, 'violation'); - }); - - test('no chmod at all => no violation regardless of shell -c', () => { - const src = `execFileSync('bash', ['-c', 'echo hi']);`; - const result = scanContent(src); - assert.strictEqual(result.makesExecutable, false, 'makesExecutable'); - assert.strictEqual(result.violation, false, 'violation'); - }); -}); diff --git a/tests/milestone-summary.test.cjs b/tests/milestone-summary.test.cjs index 53fd4a459..bc358c7f3 100644 --- a/tests/milestone-summary.test.cjs +++ b/tests/milestone-summary.test.cjs @@ -191,7 +191,7 @@ describe('milestone-summary artifact path resolution', () => { test('current milestone paths point to .planning/ root', () => { const content = fs.readFileSync(workflowPath, 'utf-8'); // Current milestone should read from .planning/ root - const lines = content.split('\n'); + const lines = content.split(/\r?\n/); const currentSection = lines.slice( lines.findIndex(l => l.includes('Current/in-progress')), lines.findIndex(l => l.includes('Current/in-progress')) + 10 diff --git a/tests/mutation-matrix-stdin-eagain.test.cjs b/tests/mutation-matrix-stdin-eagain.test.cjs new file mode 100644 index 000000000..ff54797ff --- /dev/null +++ b/tests/mutation-matrix-stdin-eagain.test.cjs @@ -0,0 +1,93 @@ +'use strict'; + +/** + * tests/mutation-matrix-stdin-eagain.test.cjs + * + * Regression tests for the EAGAIN-resilient readStdinSync() helper added to + * scripts/mutation-matrix.cjs (issue #1733). + * + * Background: On macOS, libuv sets a piped stdin fd to non-blocking mode. + * Under heavy CI shard load a synchronous fs.readFileSync(process.stdin.fd) + * can throw EAGAIN before the writer has filled the pipe, aborting the script + * with status 2. readStdinSync() retries on EAGAIN; these tests verify that + * contract deterministically by monkeypatching fs.readSync (never via chmod / + * permission tricks — see cross-platform IO-failure-injection convention). + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const matrix = require(path.resolve(__dirname, '../scripts/mutation-matrix.cjs')); + +describe('readStdinSync: EAGAIN resilience', () => { + test('exports readStdinSync as a function', () => { + assert.strictEqual( + typeof matrix.readStdinSync, + 'function', + 'mutation-matrix.cjs must export readStdinSync' + ); + }); + + test('retries on EAGAIN then returns the full payload', () => { + // Arrange: stub fs.readSync driven by a closure counter. + // Call 1 → throw EAGAIN (simulates non-blocking pipe not ready) + // Call 2 → write payload into buffer, return byte length + // Call 3+ → return 0 (clean EOF) + const payload = 'src/core-utils.cts\nsrc/adr-parser.cts\n'; + const payloadBuf = Buffer.from(payload, 'utf8'); + let callCount = 0; + + const origReadSync = fs.readSync; + try { + fs.readSync = (fd, buf, offset, _length, _position) => { + callCount++; + if (callCount === 1) { + throw Object.assign(new Error('EAGAIN: resource temporarily unavailable'), { code: 'EAGAIN' }); + } + if (callCount === 2) { + payloadBuf.copy(buf, offset, 0, payloadBuf.length); + return payloadBuf.length; + } + // Call 3+: EOF + return 0; + }; + + const result = matrix.readStdinSync(); + + assert.strictEqual( + result, + payload, + 'readStdinSync must return the full payload after retrying the EAGAIN' + ); + assert.ok( + callCount >= 3, + `expected at least 3 fs.readSync calls (EAGAIN + data + EOF), got ${callCount}` + ); + } finally { + fs.readSync = origReadSync; + } + }); + + test('non-EAGAIN errors propagate (are not swallowed)', () => { + // Arrange: stub fs.readSync to throw a non-retryable error. + const origReadSync = fs.readSync; + try { + fs.readSync = () => { + throw Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' }); + }; + + assert.throws( + () => matrix.readStdinSync(), + (err) => { + assert.strictEqual(err.code, 'EACCES'); + return true; + }, + 'readStdinSync must rethrow non-EAGAIN errors' + ); + } finally { + fs.readSync = origReadSync; + } + }); +}); diff --git a/tests/no-bare-npm-exec.rule.test.cjs b/tests/no-bare-npm-exec.rule.test.cjs new file mode 100644 index 000000000..d9c1ee46f --- /dev/null +++ b/tests/no-bare-npm-exec.rule.test.cjs @@ -0,0 +1,201 @@ +'use strict'; + +// This file is an eslint-rule RuleTester fixture. It contains npm exec +// command strings as TEST DATA (fixtures the rule must lint) — not real +// invocations. See ALLOWLIST in scripts/prompt-injection-scan.sh. + +/** + * no-bare-npm-exec.rule.test.cjs + * + * RuleTester unit tests for the local/no-bare-npm-exec ESLint rule. + * + * Rule (G5): flag execFileSync/spawnSync/spawn('npm', ...) without + * { shell: true } — Windows needs npm.cmd via a shell. + * + * execSync('npm ...') is explicitly NOT flagged: execSync always runs through + * a shell (cmd.exe on Windows resolves npm.cmd automatically), so it is safe + * without shell: true. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const { RuleTester } = require('eslint'); + +const rule = require('../eslint-rules/no-bare-npm-exec.cjs'); + +const ruleTester = new RuleTester({ + languageOptions: { + ecmaVersion: 2022, + sourceType: 'commonjs', + }, +}); + +// ─── module shape ───────────────────────────────────────────────────────────── + +describe('no-bare-npm-exec rule module', () => { + test('exports meta and create', () => { + assert.strictEqual(typeof rule.meta, 'object'); + assert.strictEqual(typeof rule.create, 'function'); + assert.strictEqual(rule.meta.type, 'problem'); + assert.ok(rule.meta.messages.bareNpmExec, 'bareNpmExec message must exist'); + }); +}); + +// ─── INVALID cases ──────────────────────────────────────────────────────────── + +describe('no-bare-npm-exec: invalid cases', () => { + test('invalid: execFileSync("npm", ["install"]) with no options', () => { + ruleTester.run('no-bare-npm-exec', rule, { + valid: [], + invalid: [ + { + code: `execFileSync('npm', ['install']);`, + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'bareNpmExec' }], + }, + ], + }); + }); + + test('invalid: execFileSync("npm", ["ci"], { cwd }) without shell', () => { + ruleTester.run('no-bare-npm-exec', rule, { + valid: [], + invalid: [ + { + code: `execFileSync('npm', ['ci'], { cwd: '/some/dir' });`, + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'bareNpmExec' }], + }, + ], + }); + }); + + test('invalid: spawnSync("npm", ["run", "build"]) with no options', () => { + ruleTester.run('no-bare-npm-exec', rule, { + valid: [], + invalid: [ + { + code: `spawnSync('npm', ['run', 'build']);`, + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'bareNpmExec' }], + }, + ], + }); + }); + + test('invalid: spawn("npm", ["install"]) with no options', () => { + ruleTester.run('no-bare-npm-exec', rule, { + valid: [], + invalid: [ + { + code: `spawn('npm', ['install']);`, + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'bareNpmExec' }], + }, + ], + }); + }); + +}); + +// ─── VALID cases ────────────────────────────────────────────────────────────── + +describe('no-bare-npm-exec: valid cases', () => { + test('valid: execFileSync("npm", ["install"], { shell: true })', () => { + ruleTester.run('no-bare-npm-exec', rule, { + valid: [ + { + code: `execFileSync('npm', ['install'], { shell: true });`, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('valid: execFileSync("npm", ["ci"], { shell: true, cwd: dir })', () => { + ruleTester.run('no-bare-npm-exec', rule, { + valid: [ + { + code: `execFileSync('npm', ['ci'], { shell: true, cwd: dir });`, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('valid: execFileSync("npm", ...) with shell: isWindows', () => { + ruleTester.run('no-bare-npm-exec', rule, { + valid: [ + { + code: `execFileSync('npm', ['install'], { shell: isWindows });`, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('valid: execFileSync("npm", ...) with shell: process.platform === "win32"', () => { + ruleTester.run('no-bare-npm-exec', rule, { + valid: [ + { + code: `execFileSync('npm', ['install'], { shell: process.platform === 'win32' });`, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('valid: spawnSync("npm", ["run", "test"], { shell: true })', () => { + ruleTester.run('no-bare-npm-exec', rule, { + valid: [ + { + code: `spawnSync('npm', ['run', 'test'], { shell: true });`, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('valid: execFileSync("node", ["script.js"]) — not npm, no flag needed', () => { + ruleTester.run('no-bare-npm-exec', rule, { + valid: [ + { + code: `execFileSync('node', ['script.js']);`, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('valid: execFileSync("npx", ["mocha"]) — not npm, different command', () => { + ruleTester.run('no-bare-npm-exec', rule, { + valid: [ + { + code: `execFileSync('npx', ['mocha']);`, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('valid: execSync("npm install") — execSync uses a shell by default, safe without shell:true', () => { + ruleTester.run('no-bare-npm-exec', rule, { + valid: [ + { + // execSync always invokes a shell (cmd.exe on Windows resolves npm.cmd), + // so it does NOT need shell: true. Rule only flags execFileSync/spawnSync/spawn. + code: `execSync('npm install');`, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); +}); diff --git a/tests/no-crlf-fragile-split.rule.test.cjs b/tests/no-crlf-fragile-split.rule.test.cjs new file mode 100644 index 000000000..bfc73c712 --- /dev/null +++ b/tests/no-crlf-fragile-split.rule.test.cjs @@ -0,0 +1,338 @@ +'use strict'; + +/** + * no-crlf-fragile-split.rule.test.cjs + * + * RuleTester unit tests for the local/no-crlf-fragile-split ESLint rule. + * + * Rule covers three sub-patterns: + * G1 — .split('\n') on readFileSync-derived content (crlfFragileSplit) + * G2 — RegExp with bare \n on readFileSync-derived content (crlfFragileRegex) + * G3 — RegExp with bare \n containing markdown fence or frontmatter anchor + * (crlfFragileRegex) — caught even without direct data-flow + * + * NOTE: Fixture code strings must encode actual \n characters as \\n inside + * the JavaScript string literals used for RuleTester `code` fields, so that + * the ESLint parser receives the intended source text. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const { RuleTester } = require('eslint'); + +const rule = require('../eslint-rules/no-crlf-fragile-split.cjs'); + +const ruleTester = new RuleTester({ + languageOptions: { + ecmaVersion: 2022, + sourceType: 'commonjs', + }, +}); + +// ─── module shape ───────────────────────────────────────────────────────────── + +describe('no-crlf-fragile-split rule module', () => { + test('exports meta and create', () => { + assert.strictEqual(typeof rule.meta, 'object'); + assert.strictEqual(typeof rule.create, 'function'); + assert.strictEqual(rule.meta.type, 'problem'); + assert.ok(rule.meta.messages.crlfFragileSplit, 'crlfFragileSplit message must exist'); + assert.ok(rule.meta.messages.crlfFragileRegex, 'crlfFragileRegex message must exist'); + }); +}); + +// ─── G1: INVALID cases ──────────────────────────────────────────────────────── + +describe('G1 — no-crlf-fragile-split: invalid (crlfFragileSplit)', () => { + test('G1-invalid: readFileSync(p).split("\\n") — direct chain', () => { + ruleTester.run('no-crlf-fragile-split', rule, { + valid: [], + invalid: [ + { + // code that ESLint will parse: fs.readFileSync(p, 'utf8').split('\n') + code: "const lines = fs.readFileSync(p, 'utf8').split('\\n');", + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'crlfFragileSplit' }], + }, + ], + }); + }); + + test('G1-invalid: readFileSync(p).toString().split("\\n") — chained call', () => { + ruleTester.run('no-crlf-fragile-split', rule, { + valid: [], + invalid: [ + { + code: "const lines = readFileSync(p).toString().split('\\n');", + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'crlfFragileSplit' }], + }, + ], + }); + }); + + test('G1-invalid: content = readFileSync(...); content.split("\\n") — via variable', () => { + ruleTester.run('no-crlf-fragile-split', rule, { + valid: [], + invalid: [ + { + code: [ + "const content = fs.readFileSync(filePath, 'utf8');", + "const lines = content.split('\\n');", + ].join('\n'), + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'crlfFragileSplit' }], + }, + ], + }); + }); + + test('G1-invalid: double-quoted "\\n" in split', () => { + ruleTester.run('no-crlf-fragile-split', rule, { + valid: [], + invalid: [ + { + code: 'const lines = fs.readFileSync(\'file.txt\', \'utf8\').split("\\n");', + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'crlfFragileSplit' }], + }, + ], + }); + }); +}); + +// ─── G1: VALID cases ────────────────────────────────────────────────────────── + +describe('G1 — no-crlf-fragile-split: valid cases', () => { + test('G1-valid: .split(/\\r?\\n/) — correct regex', () => { + ruleTester.run('no-crlf-fragile-split', rule, { + valid: [ + { + // /\r?\n/ in source — no bare \n in a string argument + code: "const lines = fs.readFileSync(p, 'utf8').split(/\\r?\\n/);", + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('G1-valid: non-file content split — "\\n" on a plain string literal', () => { + ruleTester.run('no-crlf-fragile-split', rule, { + valid: [ + { + code: "const lines = someString.split('\\n');", + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('G1-valid: non-file content split — "\\n" on variable not from readFileSync', () => { + ruleTester.run('no-crlf-fragile-split', rule, { + valid: [ + { + code: [ + "const content = 'hello\\\\nworld';", + "const lines = content.split('\\n');", + ].join('\n'), + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('G1-valid: .split("\\n") on a fetch/HTTP response (not readFileSync)', () => { + ruleTester.run('no-crlf-fragile-split', rule, { + valid: [ + { + code: "const lines = response.text.split('\\n');", + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); +}); + +// ─── G2/G3: INVALID cases ───────────────────────────────────────────────────── + +describe('G2/G3 — no-crlf-fragile-split: invalid (crlfFragileRegex)', () => { + test('G2-invalid: bare \\n in regex on readFileSync content via .match()', () => { + ruleTester.run('no-crlf-fragile-split', rule, { + valid: [], + invalid: [ + { + // /foo\nbar/ — regex with bare \n; .match() on readFileSync result + code: "const m = fs.readFileSync(p, 'utf8').match(/foo\\nbar/);", + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'crlfFragileRegex' }], + }, + ], + }); + }); + + test('G2-invalid: bare \\n in regex on readFileSync content via .test()', () => { + ruleTester.run('no-crlf-fragile-split', rule, { + valid: [], + invalid: [ + { + // /hello\nworld/.test(readFileSync(...)) + code: "const ok = /hello\\nworld/.test(fs.readFileSync(p, 'utf8'));", + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'crlfFragileRegex' }], + }, + ], + }); + }); + + test('G2-invalid: bare \\n in regex on readFileSync content via .replace()', () => { + ruleTester.run('no-crlf-fragile-split', rule, { + valid: [], + invalid: [ + { + code: "const out = fs.readFileSync(p, 'utf8').replace(/foo\\nbar/, 'x');", + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'crlfFragileRegex' }], + }, + ], + }); + }); + + test('G3-invalid: markdown fence regex with bare \\n (```bash\\n)', () => { + ruleTester.run('no-crlf-fragile-split', rule, { + valid: [], + invalid: [ + { + // content.match(/```bash\nsome/) — fence regex with bare \n + code: "const m = content.match(/```bash\\nsome/);", + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'crlfFragileRegex' }], + }, + ], + }); + }); + + test('G3-invalid: frontmatter anchor regex with bare \\n (/^---\\n/)', () => { + ruleTester.run('no-crlf-fragile-split', rule, { + valid: [], + invalid: [ + { + // /^---\ntitle/.test(content) — frontmatter with bare \n + code: "const hasFM = /^---\\ntitle/.test(content);", + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'crlfFragileRegex' }], + }, + ], + }); + }); +}); + +// ─── G2/G3: VALID cases ─────────────────────────────────────────────────────── + +describe('G2/G3 — no-crlf-fragile-split: valid cases', () => { + test('G2-valid: regex with \\r?\\n (already CRLF-safe) on readFileSync content', () => { + ruleTester.run('no-crlf-fragile-split', rule, { + valid: [ + { + // /foo\r?\nbar/ — has \r?\n so it's safe + code: "const m = fs.readFileSync(p, 'utf8').match(/foo\\r?\\nbar/);", + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('G2-valid: regex with bare \\n but used on a non-file string (ok per known boundaries)', () => { + ruleTester.run('no-crlf-fragile-split', rule, { + valid: [ + { + // /hello\nworld/.test(someRuntimeString) — not file content + code: "const ok = /hello\\nworld/.test(someRuntimeString);", + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('G3-valid: markdown fence regex but with \\r?\\n (already CRLF-safe)', () => { + ruleTester.run('no-crlf-fragile-split', rule, { + valid: [ + { + code: "const m = content.match(/```bash\\r?\\nsome/);", + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('G3-valid: frontmatter regex with \\r\\n (explicitly CRLF-safe)', () => { + ruleTester.run('no-crlf-fragile-split', rule, { + valid: [ + { + code: "const hasFM = /^---\\r\\ntitle/.test(content);", + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + // C3 per-occurrence classification cases + test('C3-valid: /\\r?\\n/ — single safe occurrence, not flagged', () => { + ruleTester.run('no-crlf-fragile-split', rule, { + valid: [ + { + code: "const m = fs.readFileSync(p, 'utf8').match(/\\r?\\n/);", + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('C3-invalid: regex with both safe \\r?\\n AND a separate bare \\n — flagged', () => { + ruleTester.run('no-crlf-fragile-split', rule, { + valid: [], + invalid: [ + { + // /\r?\nfoo|\nbar/ — the second \n (after |) is bare and fragile + code: "const m = fs.readFileSync(p, 'utf8').match(/\\r?\\nfoo|\\nbar/);", + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'crlfFragileRegex' }], + }, + ], + }); + }); + + test('C3-invalid: [^\\n] — \\n in class without \\r is fragile', () => { + ruleTester.run('no-crlf-fragile-split', rule, { + valid: [], + invalid: [ + { + // /[^\n]+/ — class has \n but no \r + code: "const m = fs.readFileSync(p, 'utf8').match(/[^\\n]+/);", + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'crlfFragileRegex' }], + }, + ], + }); + }); + + test('C3-valid: [^\\r\\n] — \\n in class with \\r is safe', () => { + ruleTester.run('no-crlf-fragile-split', rule, { + valid: [ + { + code: "const m = fs.readFileSync(p, 'utf8').match(/[^\\r\\n]+/);", + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); +}); diff --git a/tests/no-hardcoded-tmp.rule.test.cjs b/tests/no-hardcoded-tmp.rule.test.cjs new file mode 100644 index 000000000..0946176d7 --- /dev/null +++ b/tests/no-hardcoded-tmp.rule.test.cjs @@ -0,0 +1,184 @@ +'use strict'; + +/** + * no-hardcoded-tmp.rule.test.cjs + * + * RuleTester unit tests for the local/no-hardcoded-tmp ESLint rule. + * + * Rule (G4): flag a string Literal starting with `/tmp/` (or exactly `/tmp`) + * passed to an `fs.(...)` call or `path.join('/tmp/...', …)`. + * Message: use `os.tmpdir()`. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const { RuleTester } = require('eslint'); + +const rule = require('../eslint-rules/no-hardcoded-tmp.cjs'); + +const ruleTester = new RuleTester({ + languageOptions: { + ecmaVersion: 2022, + sourceType: 'commonjs', + }, +}); + +// ─── module shape ───────────────────────────────────────────────────────────── + +describe('no-hardcoded-tmp rule module', () => { + test('exports meta and create', () => { + assert.strictEqual(typeof rule.meta, 'object'); + assert.strictEqual(typeof rule.create, 'function'); + assert.strictEqual(rule.meta.type, 'problem'); + assert.ok(rule.meta.messages.hardcodedTmp, 'hardcodedTmp message must exist'); + }); +}); + +// ─── INVALID cases ──────────────────────────────────────────────────────────── + +describe('no-hardcoded-tmp: invalid cases', () => { + test('invalid: fs.writeFileSync("/tmp/x", data)', () => { + ruleTester.run('no-hardcoded-tmp', rule, { + valid: [], + invalid: [ + { + code: `fs.writeFileSync('/tmp/x', data);`, + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'hardcodedTmp' }], + }, + ], + }); + }); + + test('invalid: fs.readFileSync("/tmp/file.txt", "utf8")', () => { + ruleTester.run('no-hardcoded-tmp', rule, { + valid: [], + invalid: [ + { + code: `const c = fs.readFileSync('/tmp/file.txt', 'utf8');`, + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'hardcodedTmp' }], + }, + ], + }); + }); + + test('invalid: fs.mkdirSync("/tmp/mydir", { recursive: true })', () => { + ruleTester.run('no-hardcoded-tmp', rule, { + valid: [], + invalid: [ + { + code: `fs.mkdirSync('/tmp/mydir', { recursive: true });`, + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'hardcodedTmp' }], + }, + ], + }); + }); + + test('invalid: path.join("/tmp/dir", "sub")', () => { + ruleTester.run('no-hardcoded-tmp', rule, { + valid: [], + invalid: [ + { + code: `const p = path.join('/tmp/dir', 'sub');`, + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'hardcodedTmp' }], + }, + ], + }); + }); + + test('invalid: fs.existsSync("/tmp")', () => { + ruleTester.run('no-hardcoded-tmp', rule, { + valid: [], + invalid: [ + { + code: `fs.existsSync('/tmp');`, + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'hardcodedTmp' }], + }, + ], + }); + }); + + test('invalid: fs.rmSync("/tmp/x", { recursive: true })', () => { + ruleTester.run('no-hardcoded-tmp', rule, { + valid: [], + invalid: [ + { + code: `fs.rmSync('/tmp/x', { recursive: true });`, + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'hardcodedTmp' }], + }, + ], + }); + }); +}); + +// ─── VALID cases ────────────────────────────────────────────────────────────── + +describe('no-hardcoded-tmp: valid cases', () => { + test('valid: os.tmpdir() — portable temp directory', () => { + ruleTester.run('no-hardcoded-tmp', rule, { + valid: [ + { + code: ` + const tmpDir = os.tmpdir(); + fs.writeFileSync(path.join(tmpDir, 'x'), data); + `, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('valid: fs.writeFileSync with a variable (not hardcoded /tmp/)', () => { + ruleTester.run('no-hardcoded-tmp', rule, { + valid: [ + { + code: `fs.writeFileSync(tmpFile, data);`, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('valid: path.join with non-tmp first arg', () => { + ruleTester.run('no-hardcoded-tmp', rule, { + valid: [ + { + code: `const p = path.join(__dirname, 'fixtures', 'test.txt');`, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('valid: /tmp/ string not passed to fs or path.join (assignment)', () => { + ruleTester.run('no-hardcoded-tmp', rule, { + valid: [ + { + code: `const note = 'uses /tmp/ on POSIX';`, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('valid: /tmp/ as a non-first arg to path.join', () => { + ruleTester.run('no-hardcoded-tmp', rule, { + valid: [ + { + code: `const p = path.join(os.tmpdir(), '/tmp/subdir');`, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); +}); diff --git a/tests/no-path-literal-in-assert.rule.test.cjs b/tests/no-path-literal-in-assert.rule.test.cjs new file mode 100644 index 000000000..96490197c --- /dev/null +++ b/tests/no-path-literal-in-assert.rule.test.cjs @@ -0,0 +1,402 @@ +'use strict'; + +/** + * no-path-literal-in-assert.rule.test.cjs + * + * RuleTester unit tests for the local/no-path-literal-in-assert ESLint rule. + * Mirrors the style of tests/eslint-rules.test.cjs. + * + * Rule: report when a path-returning call (path.join, getGlobalConfigDir, …) + * is compared to a hardcoded POSIX-slash literal in an assert.*() or + * expect(…).() assertion — a DEFECT that fails on Windows. + * + * VALID (no report) when: + * - the path operand is wrapped by a POSIX normalizer (.replace, .replaceAll, toPosixPath, etc.) + * - the assertion is inside a Windows-excluded block (process.platform !== 'win32' guard, + * early-return guard, hoisted isWindows guard) + * - both operands are path calls (no slash literal involved) + * - the string literal has no slash (file-name only) + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const { RuleTester } = require('eslint'); + +const noPathLiteralInAssert = require('../eslint-rules/no-path-literal-in-assert.cjs'); + +const ruleTester = new RuleTester({ + languageOptions: { + ecmaVersion: 2022, + sourceType: 'commonjs', + }, +}); + +// ─── module shape ───────────────────────────────────────────────────────────── + +describe('no-path-literal-in-assert rule module', () => { + test('exports meta and create', () => { + assert.strictEqual(typeof noPathLiteralInAssert.meta, 'object'); + assert.strictEqual(typeof noPathLiteralInAssert.create, 'function'); + assert.strictEqual(noPathLiteralInAssert.meta.type, 'problem'); + assert.ok(noPathLiteralInAssert.meta.messages.pathLiteral); + }); +}); + +// ─── INVALID cases (violation expected) ─────────────────────────────────────── + +describe('no-path-literal-in-assert invalid cases', () => { + test('invalid: assert.strictEqual(path.join(a,b), "/x/y")', () => { + ruleTester.run('no-path-literal-in-assert', noPathLiteralInAssert, { + valid: [], + invalid: [ + { + code: `assert.strictEqual(path.join(a, b), '/x/y');`, + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'pathLiteral' }], + }, + ], + }); + }); + + test('invalid: assert.equal(getGlobalConfigDir("claude"), "/custom/claude")', () => { + ruleTester.run('no-path-literal-in-assert', noPathLiteralInAssert, { + valid: [], + invalid: [ + { + code: `assert.equal(getGlobalConfigDir('claude'), '/custom/claude');`, + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'pathLiteral' }], + }, + ], + }); + }); + + test('invalid: assert.deepStrictEqual(path.resolve(x), "/a/b")', () => { + ruleTester.run('no-path-literal-in-assert', noPathLiteralInAssert, { + valid: [], + invalid: [ + { + code: `assert.deepStrictEqual(path.resolve(x), '/a/b');`, + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'pathLiteral' }], + }, + ], + }); + }); + + test('invalid: reversed operands — assert.equal("/x/y", path.join(a,b))', () => { + ruleTester.run('no-path-literal-in-assert', noPathLiteralInAssert, { + valid: [], + invalid: [ + { + code: `assert.equal('/x/y', path.join(a, b));`, + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'pathLiteral' }], + }, + ], + }); + }); + + test('invalid: expect(path.resolve(x)).toBe("/a/b")', () => { + ruleTester.run('no-path-literal-in-assert', noPathLiteralInAssert, { + valid: [], + invalid: [ + { + code: `expect(path.resolve(x)).toBe('/a/b');`, + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'pathLiteral' }], + }, + ], + }); + }); + + test('invalid: multi-line assert.strictEqual', () => { + ruleTester.run('no-path-literal-in-assert', noPathLiteralInAssert, { + valid: [], + invalid: [ + { + code: ` + assert.strictEqual( + path.join(base, 'dir'), + '/home/user/dir' + ); + `, + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'pathLiteral' }], + }, + ], + }); + }); + + test('invalid: assert.equal(os.homedir(), "/home/user")', () => { + ruleTester.run('no-path-literal-in-assert', noPathLiteralInAssert, { + valid: [], + invalid: [ + { + code: `assert.equal(os.homedir(), '/home/user');`, + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'pathLiteral' }], + }, + ], + }); + }); + + test('invalid: assert.equal(os.tmpdir(), "/tmp")', () => { + ruleTester.run('no-path-literal-in-assert', noPathLiteralInAssert, { + valid: [], + invalid: [ + { + code: `assert.equal(os.tmpdir(), '/tmp');`, + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'pathLiteral' }], + }, + ], + }); + }); + + test('invalid: expect(getGlobalConfigDir("claude")).toEqual("/custom/claude")', () => { + ruleTester.run('no-path-literal-in-assert', noPathLiteralInAssert, { + valid: [], + invalid: [ + { + code: `expect(getGlobalConfigDir('claude')).toEqual('/custom/claude');`, + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'pathLiteral' }], + }, + ], + }); + }); + + test('invalid: assert.deepEqual(path.normalize(x), "/a/b/c")', () => { + ruleTester.run('no-path-literal-in-assert', noPathLiteralInAssert, { + valid: [], + invalid: [ + { + code: `assert.deepEqual(path.normalize(x), '/a/b/c');`, + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'pathLiteral' }], + }, + ], + }); + }); +}); + +// ─── VALID cases (no violation expected) ───────────────────────────────────── + +describe('no-path-literal-in-assert valid cases', () => { + test('valid: normalized with String(path.join).replace(/\\\\/g, "/")', () => { + ruleTester.run('no-path-literal-in-assert', noPathLiteralInAssert, { + valid: [ + { + code: `assert.equal(String(path.join(a, b)).replace(/\\\\/g, '/'), '/x/y');`, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('valid: normalized with String(path.join).replace(/[\\\\/]/g, "/")', () => { + ruleTester.run('no-path-literal-in-assert', noPathLiteralInAssert, { + valid: [ + { + code: `assert.equal(String(path.join(a, b)).replace(/[\\\\/]/g, '/'), '/x/y');`, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('valid: normalized with path.join().replaceAll(path.sep, "/")', () => { + ruleTester.run('no-path-literal-in-assert', noPathLiteralInAssert, { + valid: [ + { + code: `assert.equal(path.join(a, b).replaceAll(path.sep, '/'), '/x/y');`, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('valid: assert.equal("foo", "foo") — no path call, no slash difference', () => { + ruleTester.run('no-path-literal-in-assert', noPathLiteralInAssert, { + valid: [ + { + code: `assert.equal('foo', 'foo');`, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('valid: assert.equal(path.basename(p), "file.txt") — string has no slash', () => { + ruleTester.run('no-path-literal-in-assert', noPathLiteralInAssert, { + valid: [ + { + code: `assert.equal(path.basename(p), 'file.txt');`, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('valid: both operands are path calls — no slash literal', () => { + ruleTester.run('no-path-literal-in-assert', noPathLiteralInAssert, { + valid: [ + { + code: `assert.equal(path.join(a, b), path.join(c, d));`, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('valid: guarded by if (process.platform !== "win32") { ... }', () => { + ruleTester.run('no-path-literal-in-assert', noPathLiteralInAssert, { + valid: [ + { + code: ` + if (process.platform !== 'win32') { + assert.equal(path.join(a, b), '/x/y'); + } + `, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('valid: early-return guard — if (process.platform === "win32") return; assert.equal(path.join(a,b), "/x/y")', () => { + ruleTester.run('no-path-literal-in-assert', noPathLiteralInAssert, { + valid: [ + { + code: ` + if (process.platform === 'win32') return; + assert.equal(path.join(a, b), '/x/y'); + `, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('valid: hoisted isWindows guard — const isWindows = process.platform === "win32"; if (!isWindows) assert.equal(...)', () => { + ruleTester.run('no-path-literal-in-assert', noPathLiteralInAssert, { + valid: [ + { + code: ` + const isWindows = process.platform === 'win32'; + if (!isWindows) assert.equal(path.join(a, b), '/x/y'); + `, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('valid: assert.ok(path.join(a,b)) — not an equality assert', () => { + ruleTester.run('no-path-literal-in-assert', noPathLiteralInAssert, { + valid: [ + { + code: `assert.ok(path.join(a, b));`, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('valid: URL string starting with https:// is not flagged', () => { + ruleTester.run('no-path-literal-in-assert', noPathLiteralInAssert, { + valid: [ + { + code: `assert.equal(getUrl(), 'https://example.com/path');`, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('valid: normalized with toPosixPath(path.join(a,b))', () => { + ruleTester.run('no-path-literal-in-assert', noPathLiteralInAssert, { + valid: [ + { + code: `assert.equal(toPosixPath(path.join(a, b)), '/x/y');`, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); +}); + +// ─── C1: isPosixNormalizerCall must require backslash-targeting regex ───────── +// The fix: .replace(/foo/g,'/') and .replace(/\//g,'/') must NOT suppress the rule. +// Before the fix, ANY .replace(//g, '/') was accepted as a normalizer +// and would suppress the violation even when the regex did not target backslashes. + +describe('C1 — isPosixNormalizerCall backslash-targeting requirement', () => { + test('C1 INVALID: path.join().replace(/foo/g, "/") is NOT a POSIX normalizer — flagged', () => { + // Before C1 fix: was NOT flagged (any regex with g flag was accepted as normalizer). + // After C1 fix: IS flagged (/foo/g does not target backslashes → not a normalizer; + // rule now peels the non-normalizer method chain and finds path.join inside). + ruleTester.run('no-path-literal-in-assert', noPathLiteralInAssert, { + valid: [], + invalid: [ + { + code: `assert.equal(path.join(a,b).replace(/foo/g, '/'), '/x/y');`, + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'pathLiteral' }], + }, + ], + }); + }); + + test('C1 INVALID: path.join().replace(/\\//g, "/") targets forward-slash only — flagged', () => { + // Before C1 fix: was NOT flagged. + // After C1 fix: IS flagged (/\//g matches forward-slash only, not backslash → not a normalizer). + ruleTester.run('no-path-literal-in-assert', noPathLiteralInAssert, { + valid: [], + invalid: [ + { + code: `assert.equal(path.join(a,b).replace(/\\//g, '/'), '/x/y');`, + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'pathLiteral' }], + }, + ], + }); + }); + + test('C1 VALID: path.join().replace(/\\\\/g, "/") IS a proper POSIX normalizer — NOT flagged', () => { + ruleTester.run('no-path-literal-in-assert', noPathLiteralInAssert, { + valid: [ + { + code: `assert.equal(path.join(a,b).replace(/\\\\/g, '/'), '/x/y');`, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('C1 VALID: path.join().replace(/[\\\\/]/g, "/") IS a proper POSIX normalizer — NOT flagged', () => { + ruleTester.run('no-path-literal-in-assert', noPathLiteralInAssert, { + valid: [ + { + code: `assert.equal(path.join(a,b).replace(/[\\\\/]/g, '/'), '/x/y');`, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); +}); diff --git a/tests/no-posix-mode-bit-assert.rule.test.cjs b/tests/no-posix-mode-bit-assert.rule.test.cjs new file mode 100644 index 000000000..135465685 --- /dev/null +++ b/tests/no-posix-mode-bit-assert.rule.test.cjs @@ -0,0 +1,549 @@ +'use strict'; + +/** + * no-posix-mode-bit-assert.rule.test.cjs + * + * RuleTester unit tests for the local/no-posix-mode-bit-assert ESLint rule. + * Mirrors the style of tests/no-path-literal-in-assert.rule.test.cjs. + * + * Rule: report when a file-mode expression is compared to an octal literal in + * an assert.*() or expect(…).() assertion — a DEFECT that fails on + * Windows because Windows reports 0o666/0o444 (DOS attributes), never the + * requested POSIX octal. + * + * "File-mode expression" is: + * M0. Direct/chained `.mode` MemberExpression (non-computed) + * M1. Computed member `x['mode']` + * M2. Variable capture: `const m = stat.mode` / `const m = stat['mode']` + * resolved via scope — m & 0o777 or m === 0o644 is flagged. + * Unresolvable bare identifier (no in-file binding) is NOT flagged. + * M3. Destructure: `const { mode } = fs.statSync(p)` — mode binding flagged. + * M4. Wrapper: `Number(stat.mode & 0o777)` / `parseInt(stat.mode, 8)` inside + * the assertion operand — recurses into the wrapper's first argument. + * + * DEFECT category: DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT + * + * VALID (no report) when: + * - the assertion is inside a Windows-excluded block (platform guard, + * early-return guard, hoisted isWindows) as detected by platform-guard.cjs + * - neither operand resolves to a mode expression + * - the mode field is compared to a variable (not an octal literal) + * - a bare Identifier whose binding is unresolvable in-file is NOT flagged + * (conservative — avoids false positives on non-fs identifiers) + * + * Note on non-fs `.mode`: the rule intentionally flags ANY `.mode`-vs-octal- + * literal equality assertion. It cannot distinguish `fs.statSync().mode` from + * an unrelated `obj.mode`, and a non-fs `.mode` compared to an octal literal + * is vanishingly rare in test code. The defect shape (POSIX-mode assertion that + * fails on Windows) is the primary concern. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const { RuleTester } = require('eslint'); + +const noPosixModeBitAssert = require('../eslint-rules/no-posix-mode-bit-assert.cjs'); + +const ruleTester = new RuleTester({ + languageOptions: { + ecmaVersion: 2022, + sourceType: 'commonjs', + }, +}); + +// ─── module shape ───────────────────────────────────────────────────────────── + +describe('no-posix-mode-bit-assert rule module', () => { + test('exports meta and create', () => { + assert.strictEqual(typeof noPosixModeBitAssert.meta, 'object'); + assert.strictEqual(typeof noPosixModeBitAssert.create, 'function'); + assert.strictEqual(noPosixModeBitAssert.meta.type, 'problem'); + assert.ok(noPosixModeBitAssert.meta.messages.posixModeBit); + }); +}); + +// ─── INVALID cases (violation expected) ─────────────────────────────────────── + +describe('no-posix-mode-bit-assert invalid cases', () => { + test('invalid: assert.strictEqual(fs.statSync(p).mode & 0o777, 0o644)', () => { + ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, { + valid: [], + invalid: [ + { + code: `assert.strictEqual(fs.statSync(p).mode & 0o777, 0o644);`, + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'posixModeBit' }], + }, + ], + }); + }); + + test('invalid: assert.equal(statSync(p).mode & 0o111, 0)', () => { + // 0 is a decimal literal but the mode mask 0o111 is an octal — the mask side + // determines the defect shape (bitwise mask on .mode with an octal). + ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, { + valid: [], + invalid: [ + { + code: `assert.equal(statSync(p).mode & 0o111, 0);`, + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'posixModeBit' }], + }, + ], + }); + }); + + test('invalid: direct .mode in the assertion — assert.strictEqual(fs.statSync(p).mode & 0o777, 0o755)', () => { + // The assertion operand contains `.mode` directly (not via a variable). + // This is always detected regardless of surrounding context. + ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, { + valid: [], + invalid: [ + { + // .mode is directly in the masked expression inside the assert + code: `const m = fs.statSync(p).mode; assert.strictEqual(fs.statSync(p).mode & 0o777, 0o755);`, + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'posixModeBit' }], + }, + ], + }); + }); + + test('invalid: assert.strictEqual(fs.statSync(p).mode, 0o100644) — direct mode comparison', () => { + ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, { + valid: [], + invalid: [ + { + code: `assert.strictEqual(fs.statSync(p).mode, 0o100644);`, + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'posixModeBit' }], + }, + ], + }); + }); + + test('invalid: expect(statSync(p).mode & 0o777).toBe(0o644)', () => { + ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, { + valid: [], + invalid: [ + { + code: `expect(statSync(p).mode & 0o777).toBe(0o644);`, + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'posixModeBit' }], + }, + ], + }); + }); + + test('invalid: assert.deepEqual with mode mask', () => { + ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, { + valid: [], + invalid: [ + { + code: `assert.deepEqual(fs.statSync(p).mode & 0o777, 0o755);`, + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'posixModeBit' }], + }, + ], + }); + }); + + test('invalid: assert.deepStrictEqual with direct mode comparison', () => { + ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, { + valid: [], + invalid: [ + { + code: `assert.deepStrictEqual(fs.statSync(f).mode, 0o100755);`, + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'posixModeBit' }], + }, + ], + }); + }); + + test('invalid: expect(statSync(p).mode & 0o777).toEqual(0o644)', () => { + ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, { + valid: [], + invalid: [ + { + code: `expect(statSync(p).mode & 0o777).toEqual(0o644);`, + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'posixModeBit' }], + }, + ], + }); + }); + + test('invalid: expect(statSync(p).mode & 0o777).toStrictEqual(0o644)', () => { + ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, { + valid: [], + invalid: [ + { + code: `expect(statSync(p).mode & 0o777).toStrictEqual(0o644);`, + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'posixModeBit' }], + }, + ], + }); + }); + + test.skip('invalid: legacy octal (0644) — espree ecmaVersion:2022 rejects the syntax; out of scope', () => {}); + + test('invalid: x.mode compared to octal (simple MemberExpression .mode)', () => { + ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, { + valid: [], + invalid: [ + { + code: `assert.strictEqual(x.mode, 0o644);`, + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'posixModeBit' }], + }, + ], + }); + }); + + test('invalid: reversed operands — assert.strictEqual(0o644, fs.statSync(p).mode & 0o777)', () => { + ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, { + valid: [], + invalid: [ + { + code: `assert.strictEqual(0o644, fs.statSync(p).mode & 0o777);`, + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'posixModeBit' }], + }, + ], + }); + }); + + // ── M1: computed member x['mode'] ───────────────────────────────────────── + + test('invalid M1: assert.strictEqual(stat["mode"] & 0o777, 0o644) — computed member', () => { + ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, { + valid: [], + invalid: [ + { + code: `assert.strictEqual(stat['mode'] & 0o777, 0o644);`, + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'posixModeBit' }], + }, + ], + }); + }); + + test('invalid M1: assert.strictEqual(fs.statSync(p)["mode"], 0o100644) — computed member direct', () => { + ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, { + valid: [], + invalid: [ + { + code: `assert.strictEqual(fs.statSync(p)['mode'], 0o100644);`, + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'posixModeBit' }], + }, + ], + }); + }); + + // ── M2: variable capture const m = stat.mode ────────────────────────────── + + test('invalid M2: const m = fs.statSync(p).mode; assert.strictEqual(m & 0o777, 0o644)', () => { + ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, { + valid: [], + invalid: [ + { + code: `const m = fs.statSync(p).mode; assert.strictEqual(m & 0o777, 0o644);`, + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'posixModeBit' }], + }, + ], + }); + }); + + test('invalid M2: const m = stat["mode"]; assert.strictEqual(m & 0o777, 0o644) — computed-member capture', () => { + ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, { + valid: [], + invalid: [ + { + code: `const m = stat['mode']; assert.strictEqual(m & 0o777, 0o644);`, + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'posixModeBit' }], + }, + ], + }); + }); + + test('invalid M2: const m = fs.statSync(p).mode; assert.strictEqual(m, 0o100644) — direct comparison', () => { + ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, { + valid: [], + invalid: [ + { + code: `const m = fs.statSync(p).mode; assert.strictEqual(m, 0o100644);`, + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'posixModeBit' }], + }, + ], + }); + }); + + // ── M3: destructuring const { mode } = fs.statSync(p) ──────────────────── + + test('invalid M3: const { mode } = fs.statSync(p); assert.strictEqual(mode & 0o777, 0o644)', () => { + ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, { + valid: [], + invalid: [ + { + code: `const { mode } = fs.statSync(p); assert.strictEqual(mode & 0o777, 0o644);`, + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'posixModeBit' }], + }, + ], + }); + }); + + test('invalid M3: const { mode } = lstatSync(p); assert.strictEqual(mode, 0o100755)', () => { + ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, { + valid: [], + invalid: [ + { + code: `const { mode } = lstatSync(p); assert.strictEqual(mode, 0o100755);`, + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'posixModeBit' }], + }, + ], + }); + }); + + // ── M4: wrapper Number(...) / parseInt(...) ──────────────────────────────── + + test('invalid M4: assert.strictEqual(Number(fs.statSync(p).mode & 0o777), 0o644)', () => { + ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, { + valid: [], + invalid: [ + { + code: `assert.strictEqual(Number(fs.statSync(p).mode & 0o777), 0o644);`, + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'posixModeBit' }], + }, + ], + }); + }); + + test('invalid M4: assert.strictEqual(parseInt(fs.statSync(p).mode, 8) & 0o777, 0o644)', () => { + ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, { + valid: [], + invalid: [ + { + code: `assert.strictEqual(parseInt(fs.statSync(p).mode, 8) & 0o777, 0o644);`, + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'posixModeBit' }], + }, + ], + }); + }); + + test('invalid M4: assert.strictEqual(Number(stat.mode), 0o644) — Number wrapper direct', () => { + ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, { + valid: [], + invalid: [ + { + code: `assert.strictEqual(Number(stat.mode), 0o644);`, + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'posixModeBit' }], + }, + ], + }); + }); +}); + +// ─── VALID cases (no violation expected) ───────────────────────────────────── + +describe('no-posix-mode-bit-assert valid cases', () => { + test('valid: guarded by if (process.platform !== "win32") { ... }', () => { + ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, { + valid: [ + { + code: ` + if (process.platform !== 'win32') { + assert.strictEqual(statSync(p).mode & 0o777, 0o644); + } + `, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('valid: early-return guard — if (process.platform === "win32") return; assert.strictEqual(mode)', () => { + ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, { + valid: [ + { + code: ` + function test() { + if (process.platform === 'win32') return; + assert.strictEqual(statSync(p).mode & 0o777, 0o644); + } + `, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('valid: assert.equal(config.timeout, 0o644) — octal but no .mode → not flagged', () => { + ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, { + valid: [ + { + code: `assert.equal(config.timeout, 0o644);`, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('valid: assert.equal(result.mode, "r") — .mode but no octal → not flagged', () => { + ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, { + valid: [ + { + code: `assert.equal(result.mode, 'r');`, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('valid: assert.equal(file.mode, expectedMode) — .mode but expected is a variable → not flagged', () => { + ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, { + valid: [ + { + code: `assert.equal(file.mode, expectedMode);`, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('valid: assert.ok(fs.statSync(p).mode) — not an equality assertion', () => { + ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, { + valid: [ + { + code: `assert.ok(fs.statSync(p).mode);`, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('valid: assert.equal(x, y) — no .mode, no octal', () => { + ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, { + valid: [ + { + code: `assert.equal(x, y);`, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('valid: assert.strictEqual(count, 0o777) — octal but no .mode in assertion operands → not flagged', () => { + // 0o777 is an octal, count is a bare identifier, no .mode → out of scope + ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, { + valid: [ + { + code: `assert.strictEqual(count, 0o777);`, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('valid: assert.notStrictEqual(fs.statSync(p).mode & 0o777, 0o644) — inequality assertion, not flagged', () => { + // Inequality assertions pass on Windows regardless, so are out of scope. + ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, { + valid: [ + { + code: `assert.notStrictEqual(fs.statSync(p).mode & 0o777, 0o644);`, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('valid: hoisted isWindows guard', () => { + ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, { + valid: [ + { + code: ` + const isWindows = process.platform === 'win32'; + if (!isWindows) { + assert.strictEqual(statSync(p).mode & 0o777, 0o644); + } + `, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('valid: assert.equal(result.mode, result.mode) — no octal involved', () => { + ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, { + valid: [ + { + code: `assert.equal(result.mode, result.mode);`, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('valid (conservative): unresolvable bare identifier m & 0o777 — no in-file binding → NOT flagged', () => { + // `m` has no in-file `const m = …mode` declaration (it could be an import, + // a function parameter, or an unrelated local). The rule is conservative: + // it only flags when the binding RESOLVES to a mode expression in-file. + ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, { + valid: [ + { + code: `assert.strictEqual(m & 0o777, 0o644);`, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('valid (conservative): variable capture with non-mode init — assert.strictEqual(m & 0o777, 0o644) NOT flagged when m = config.timeout', () => { + // `m` is initialized to something that is NOT a mode expression; should not flag. + ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, { + valid: [ + { + code: `const m = config.timeout; assert.strictEqual(m & 0o777, 0o644);`, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('valid (conservative): M2 variable reassigned before assert — NOT flagged (reassignment invalidates alias)', () => { + // `m` was initialized to a mode expression but then reassigned; conservative + // approach — do not flag when init cannot be trusted as the current value. + ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, { + valid: [ + { + code: `const m = fs.statSync(p).mode; m = 0; assert.strictEqual(m & 0o777, 0o644);`, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); +}); diff --git a/tests/no-unguarded-nonportable-exec.rule.test.cjs b/tests/no-unguarded-nonportable-exec.rule.test.cjs new file mode 100644 index 000000000..74758a194 --- /dev/null +++ b/tests/no-unguarded-nonportable-exec.rule.test.cjs @@ -0,0 +1,300 @@ +'use strict'; + +/** + * no-unguarded-nonportable-exec.rule.test.cjs + * + * RuleTester unit tests for the local/no-unguarded-nonportable-exec ESLint rule. + * + * Rule: at Program:exit, if the file contains any chmod call with an exec-bit + * octal (0oNNN & 0o111 !== 0), report each sh/bash -c invocation + * (execFileSync/spawnSync/spawn/exec/execSync) that is NOT inside a Windows + * platform guard. + * + * DEFECT category: DEFECT.WINDOWS-TEST-PORTABILITY + * + * Test cases mirror those in the retired regex-based script + * scripts/lint-windows-test-portability.cjs. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const { RuleTester } = require('eslint'); + +const rule = require('../eslint-rules/no-unguarded-nonportable-exec.cjs'); + +const ruleTester = new RuleTester({ + languageOptions: { + ecmaVersion: 2022, + sourceType: 'commonjs', + }, +}); + +// ─── module shape ───────────────────────────────────────────────────────────── + +describe('no-unguarded-nonportable-exec rule module', () => { + test('exports meta and create', () => { + assert.strictEqual(typeof rule.meta, 'object'); + assert.strictEqual(typeof rule.create, 'function'); + assert.strictEqual(rule.meta.type, 'problem'); + assert.ok(rule.meta.messages.nonportableExec); + }); +}); + +// ─── INVALID cases (violation expected) ─────────────────────────────────────── + +describe('no-unguarded-nonportable-exec invalid cases', () => { + test('invalid: chmod 0o755 + execFileSync bash -c with no guard', () => { + ruleTester.run('no-unguarded-nonportable-exec', rule, { + valid: [], + invalid: [ + { + code: ` + fs.chmodSync(fixture, 0o755); + execFileSync('bash', ['-c', 'echo hi']); + `, + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'nonportableExec' }], + }, + ], + }); + }); + + test('invalid: chmod 0o111 (pure exec bits) + spawnSync sh -c', () => { + ruleTester.run('no-unguarded-nonportable-exec', rule, { + valid: [], + invalid: [ + { + code: ` + fs.chmodSync(f, 0o111); + spawnSync('sh', ['-c', 'x']); + `, + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'nonportableExec' }], + }, + ], + }); + }); + + test('invalid: string-form exec(sh -c) + chmod 0o755', () => { + ruleTester.run('no-unguarded-nonportable-exec', rule, { + valid: [], + invalid: [ + { + code: ` + fs.chmodSync(f, 0o755); + exec('sh -c "run.sh"'); + `, + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'nonportableExec' }], + }, + ], + }); + }); + + test('invalid: /bin/bash prefix in array form + chmod exec bit', () => { + ruleTester.run('no-unguarded-nonportable-exec', rule, { + valid: [], + invalid: [ + { + code: ` + fs.chmodSync(f, 0o755); + execFileSync('/bin/bash', ['-c', 'run']); + `, + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'nonportableExec' }], + }, + ], + }); + }); +}); + +// ─── VALID cases (no violation expected) ────────────────────────────────────── + +describe('no-unguarded-nonportable-exec valid cases', () => { + test('valid: chmod 0o755 + execFileSync bash -c with process.platform guard', () => { + ruleTester.run('no-unguarded-nonportable-exec', rule, { + valid: [ + { + code: ` + fs.chmodSync(fixture, 0o755); + if (process.platform !== 'win32') { + execFileSync('bash', ['-c', 'echo hi']); + } + `, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('valid: chmod 0o644 (no exec bit) + bash -c does not trigger', () => { + ruleTester.run('no-unguarded-nonportable-exec', rule, { + valid: [ + { + code: ` + fs.chmodSync(fixture, 0o644); + execFileSync('bash', ['-c', 'cat file']); + `, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('valid: chmod 0o755 + execFileSync(sh, [path]) no -c flag', () => { + ruleTester.run('no-unguarded-nonportable-exec', rule, { + valid: [ + { + code: ` + fs.chmodSync(fixture, 0o755); + execFileSync('sh', [fixturePath]); + `, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('valid: no chmod at all — bash -c alone is fine', () => { + ruleTester.run('no-unguarded-nonportable-exec', rule, { + valid: [ + { + code: `execFileSync('bash', ['-c', 'echo hi']);`, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('valid: chmod 0o444 (read-only, no exec bits) + bash -c', () => { + ruleTester.run('no-unguarded-nonportable-exec', rule, { + valid: [ + { + code: ` + fs.chmodSync(f, 0o444); + execFileSync('bash', ['-c', 'x']); + `, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('valid: early-return windows guard before sh -c', () => { + ruleTester.run('no-unguarded-nonportable-exec', rule, { + valid: [ + { + code: ` + fs.chmodSync(f, 0o755); + if (process.platform === 'win32') return; + execFileSync('sh', ['-c', 'run']); + `, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('valid: hoisted isWindows guard', () => { + ruleTester.run('no-unguarded-nonportable-exec', rule, { + valid: [ + { + code: ` + const isWindows = process.platform === 'win32'; + fs.chmodSync(f, 0o755); + if (!isWindows) { + execFileSync('bash', ['-c', 'run']); + } + `, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('valid: chmod 0o755 + exec("finish -c something") — "sh" in "finish" is not a shell invocation (W1 word-boundary)', () => { + ruleTester.run('no-unguarded-nonportable-exec', rule, { + valid: [ + { + code: ` + fs.chmodSync(fixture, 0o755); + exec('finish -c something'); + `, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('valid: chmod 0o755 + exec("publish -c") — "sh" in "publish" is not a shell invocation (W1 word-boundary)', () => { + ruleTester.run('no-unguarded-nonportable-exec', rule, { + valid: [ + { + code: ` + fs.chmodSync(fixture, 0o755); + exec('publish -c'); + `, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + // C1 fix: '-c' must be FIRST element; a script receiving '-c' as a later arg + // is not a shell -c invocation. + test('valid (C1): chmod 0o755 + execFileSync(sh, [fixturePath, "-c"]) — script arg, not shell -c', () => { + ruleTester.run('no-unguarded-nonportable-exec', rule, { + valid: [ + { + code: ` + fs.chmodSync(fixturePath, 0o755); + execFileSync('sh', [fixturePath, '-c']); + `, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + // C2 fix: 'sh -c' embedded mid-string in data (as arg to printf) must NOT flag. + test('valid (C2): chmod 0o755 + exec("printf \\"sh -c\\"") — sh -c as data, not a shell invocation', () => { + ruleTester.run('no-unguarded-nonportable-exec', rule, { + valid: [ + { + code: ` + fs.chmodSync(fixture, 0o755); + exec('printf "sh -c"'); + `, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + // C2 fix: 'sh -c' embedded after other words must NOT flag. + test('valid (C2): chmod 0o755 + exec("echo run sh -c later") — sh -c mid-string as data', () => { + ruleTester.run('no-unguarded-nonportable-exec', rule, { + valid: [ + { + code: ` + fs.chmodSync(fixture, 0o755); + exec('echo run sh -c later'); + `, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); +}); diff --git a/tests/non-claude-runtimes-registry-derivation.test.cjs b/tests/non-claude-runtimes-registry-derivation.test.cjs new file mode 100644 index 000000000..50293358b --- /dev/null +++ b/tests/non-claude-runtimes-registry-derivation.test.cjs @@ -0,0 +1,81 @@ +'use strict'; +/** + * Drift-guard: NON_CLAUDE_RUNTIMES must always be derived from the capability + * registry. Verifies: + * 1. The exported constant equals a hardcoded golden expected list — a pinned + * oracle that catches BOTH formula bugs (derived value diverges from golden) + * AND unintended registry drift (adding/removing a runtime forces a + * deliberate golden-list update). + * 2. Every registry entry with role === 'runtime' and id !== 'claude' appears + * in NON_CLAUDE_RUNTIMES — a cross-check from a different angle than the + * production derivation formula. + * 3. Every member of NON_CLAUDE_RUNTIMES has an explicit getDirName branch that + * does not return '.claude' — guards against adding a runtime to the registry + * without teaching getDirName about it (ADR-1239 Phase B, #1679). + * + * Behavioral tests only: assert on returned values, no source-grep. + */ + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const conversion = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs'); +const registry = require('../gsd-core/bin/lib/capability-registry.cjs'); +const runtimeNamePolicy = require('../gsd-core/bin/lib/runtime-name-policy.cjs'); + +const { NON_CLAUDE_RUNTIMES } = conversion; +const { getDirName } = runtimeNamePolicy; + +// Golden oracle: hardcoded sorted known-good list of all non-Claude runtimes. +// A pinned expected value in a TEST is correct — the test IS the oracle. +// Only PRODUCTION code should derive dynamically from the registry. +// If this list diverges from NON_CLAUDE_RUNTIMES, either the formula is wrong +// OR the registry changed — both require a deliberate golden-list update here. +const EXPECTED = [ + 'antigravity', 'augment', 'cline', 'codebuddy', 'codex', 'copilot', + 'cursor', 'gemini', 'hermes', 'kilo', 'kimi', 'opencode', 'qwen', + 'trae', 'windsurf', +]; + +test('NON_CLAUDE_RUNTIMES matches the golden expected set (sorted)', () => { + assert.deepEqual( + [...NON_CLAUDE_RUNTIMES], + EXPECTED, + `NON_CLAUDE_RUNTIMES diverged from golden list.\n` + + ` actual: [${[...NON_CLAUDE_RUNTIMES].join(', ')}]\n` + + ` expected: [${EXPECTED.join(', ')}]`, + ); + // Explicit readability assertion: 'claude' must never appear. + assert.ok( + !NON_CLAUDE_RUNTIMES.includes('claude'), + 'NON_CLAUDE_RUNTIMES must not contain "claude"', + ); +}); + +test('every registry-declared runtime except claude is present in NON_CLAUDE_RUNTIMES', () => { + // Cross-check from a DIFFERENT angle than the production derivation formula: + // iterate registry entries by their role field rather than by Object.keys().filter(). + // This catches a case where a runtime is added to the registry with role==='runtime' + // but is somehow excluded from NON_CLAUDE_RUNTIMES by a formula bug. + for (const [id, entry] of Object.entries(registry.runtimes)) { + if (entry.role === 'runtime' && id !== 'claude') { + assert.ok( + NON_CLAUDE_RUNTIMES.includes(id), + `Registry declares runtime '${id}' (role==='runtime') but it is missing from NON_CLAUDE_RUNTIMES`, + ); + } + } +}); + +// Forward direction (registry → getDirName coverage) is the load-bearing guard: +// the registry is the authoritative runtime source, so every member of +// NON_CLAUDE_RUNTIMES must have an explicit getDirName branch. +test('DRIFT GUARD: every registry-declared non-Claude runtime has an explicit getDirName branch (not .claude)', () => { + for (const rt of NON_CLAUDE_RUNTIMES) { + const dir = getDirName(rt); + assert.notEqual( + dir, + '.claude', + `getDirName('${rt}') returned '.claude' — runtime '${rt}' is in the registry but missing an explicit getDirName branch`, + ); + } +}); diff --git a/tests/normalize-path-in-content.rule.test.cjs b/tests/normalize-path-in-content.rule.test.cjs new file mode 100644 index 000000000..ebfffa784 --- /dev/null +++ b/tests/normalize-path-in-content.rule.test.cjs @@ -0,0 +1,541 @@ +'use strict'; + +/** + * normalize-path-in-content.rule.test.cjs + * + * RuleTester unit tests for the local/normalize-path-in-content ESLint rule. + * + * Rule: flag a path-returning fn result (PATH_RETURNING_FNS minus path.basename) + * interpolated into a content template literal (`${ … }`) without POSIX + * normalization. "Content" is identified by two shapes: + * + * Shape (a) — quasis contain an @-reference or home-dir prefix marker: + * `@~/`, `@$`, `@/`, `$HOME`, `~/` in the template's static parts. + * A bare `@` not followed by `~`, `$`, or `/` is NOT a marker. + * + * Shape (b) — per-expression quasi check: quasis[i+1].raw starts with `/` + * and contains `.md` or `.json` at the end of a path component. Catches + * `${computePathPrefix(t)}/commands/gsd/x.md` without config-dir markers. + * + * Config-dir substrings (`/.claude`, `/commands`, `/skills`, etc.) are NOT + * content markers — removed to eliminate diagnostic/log FPs. + * + * DEFECT category: DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT + * RULESET: RULESET.CONTENT-PATH-NORMALIZATION + * + * INVALID (violation expected): + * - path.join(home, '.claude') interpolated into a template containing @~/ + * - computePathPrefix(...) interpolated into a template with /commands/gsd/x.md + * (shape b: quasi immediately after starts with /…\.md) + * - getGlobalConfigDir() in @${fn()}/commands/gsd/help.md (shape b) + * + * VALID (no violation): + * - path.basename(p) in any content template — basename cannot contain separators (N1). + * - Path normalized via .replace(/\\/g, '/') before interpolation + * - Path normalized via String(...).replace(...) before interpolation + * - Path in console.log() — log message, not content + * - Path in fs.writeFileSync() first arg — real FS path, not content + * - Path in new Error() — diagnostic, not content + * - throw Error(...) — bare Error() call is a diagnostic, not content (W2) + * - Template literal with NO content markers — e.g. just a log string + * - path.basename(outputPath) in a status message with bare .md prose — N2 + * - Template with a bare `@` that is not an @-reference (@-narrowing precision) + * - path.resolve(configDir) in `${fn()}/.claude/x` — no .md/.json and no + * @-ref markers → not flagged (narrowed from Tier 2 / config-dir markers) + * - os.homedir() in `${fn()}/.claude/gsd-core/commands` — same: no .md/.json + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const { RuleTester } = require('eslint'); + +const normalizePathInContent = require('../eslint-rules/normalize-path-in-content.cjs'); + +const ruleTester = new RuleTester({ + languageOptions: { + ecmaVersion: 2022, + sourceType: 'commonjs', + }, +}); + +// ─── module shape ───────────────────────────────────────────────────────────── + +describe('normalize-path-in-content rule module', () => { + test('exports meta and create', () => { + assert.strictEqual(typeof normalizePathInContent.meta, 'object'); + assert.strictEqual(typeof normalizePathInContent.create, 'function'); + assert.strictEqual(normalizePathInContent.meta.type, 'problem'); + assert.ok(normalizePathInContent.meta.messages.pathInContent); + }); +}); + +// ─── INVALID cases (violation expected) ─────────────────────────────────────── + +describe('normalize-path-in-content invalid cases', () => { + test('invalid: path.join(home, ".claude") in @~/ home-dir reference template', () => { + ruleTester.run('normalize-path-in-content', normalizePathInContent, { + valid: [], + invalid: [ + { + // Canonical defect shape: path.join result into a markdown @~/ reference + // without normalization. On Windows, path.join emits backslashes. + // The '@~/' quasi prefix is the genuine @-reference marker (not bare '@'). + code: "const ref = `@~/${path.join(home, '.claude')}/x.md`;", + errors: [{ messageId: 'pathInContent' }], + }, + ], + }); + }); + + test('invalid: computePathPrefix result in content template containing .md reference', () => { + ruleTester.run('normalize-path-in-content', normalizePathInContent, { + valid: [], + invalid: [ + { + // computePathPrefix is a PATH_RETURNING_FNS entry; its result used + // directly in a template with a .md path reference is flagged. + code: `const body = \`\${computePathPrefix(t)}/commands/gsd/x.md\`;`, + errors: [{ messageId: 'pathInContent' }], + }, + ], + }); + }); + + test('valid (narrowed): path.resolve(configDir) in template with /.claude/x — no .md/.json after expr and no @-ref marker → NOT flagged after Tier 2 marker removal', () => { + // /.claude was a Tier 2 marker but was removed to eliminate diagnostic FPs. + // Shape (b) requires the quasi immediately after the expression to start with + // /…\.md or /…\.json — /.claude/x does NOT end in .md/.json so this is now VALID. + ruleTester.run('normalize-path-in-content', normalizePathInContent, { + valid: [ + { + code: `const prefix = \`\${path.resolve(configDir)}/.claude/x\`;`, + }, + ], + invalid: [], + }); + }); + + test('invalid: getGlobalConfigDir("claude") in @${fn()}/commands/gsd/help.md — shape (b) fires: quasi after expr starts with /commands/gsd/help.md', () => { + ruleTester.run('normalize-path-in-content', normalizePathInContent, { + valid: [], + invalid: [ + { + // Shape (b): quasi[1] = '/commands/gsd/help.md' starts with '/' and ends with '.md' + // → QUASI_MD_JSON_RE matches → path call is flagged regardless of config-dir markers. + // Note: bare '@' in quasi[0] is NOT a content marker (only @~, @$, @/ are); + // this case is detected by shape (b) alone. + code: `const ref = \`@\${getGlobalConfigDir('claude')}/commands/gsd/help.md\`;`, + errors: [{ messageId: 'pathInContent' }], + }, + ], + }); + }); + + test('valid (narrowed): os.homedir() in template /.claude/gsd-core/commands — no .md/.json and no @-ref → NOT flagged after Tier 2 marker removal', () => { + // /.claude was a Tier 2 marker but was removed to eliminate diagnostic FPs. + // Shape (b) requires /…\.md or /…\.json immediately after the expression. + // /.claude/gsd-core/commands has no .md/.json → VALID. + ruleTester.run('normalize-path-in-content', normalizePathInContent, { + valid: [ + { + code: `const body = \`\${os.homedir()}/.claude/gsd-core/commands\`;`, + }, + ], + invalid: [], + }); + }); + + test('invalid: String() wrapping without normalization is still flagged', () => { + ruleTester.run('normalize-path-in-content', normalizePathInContent, { + valid: [], + invalid: [ + { + // String() alone is not a POSIX normalizer — the replace() is still needed. + // Uses @~/ marker so the content heuristic fires (bare '@' is no longer a marker). + code: "const ref = `@~/${String(path.join(home, '.claude'))}/x.md`;", + errors: [{ messageId: 'pathInContent' }], + }, + ], + }); + }); +}); + +// ─── VALID cases (no violation) ─────────────────────────────────────────────── + +describe('normalize-path-in-content valid cases', () => { + test('valid: path normalized with .replace(/\\\\/g, "/") before interpolation', () => { + ruleTester.run('normalize-path-in-content', normalizePathInContent, { + valid: [ + { + // Correct POSIX normalization via .replace before interpolation into @~/ reference. + // Uses '@~/' marker so the content heuristic fires, then confirms no violation. + code: "const ref = `@~/${String(path.join(home, '.claude')).replace(/\\\\/g, '/')}/x.md`;", + }, + ], + invalid: [], + }); + }); + + test('valid: toPosixPath() wrapper before interpolation', () => { + ruleTester.run('normalize-path-in-content', normalizePathInContent, { + valid: [ + { + code: `const body = \`\${toPosixPath(path.resolve(configDir))}/commands/gsd/x.md\`;`, + }, + ], + invalid: [], + }); + }); + + test('valid: console.log with path — log line, not content (no content markers = no flag)', () => { + ruleTester.run('normalize-path-in-content', normalizePathInContent, { + valid: [ + { + // Log line: no @, $HOME, .md, /gsd etc. in the template quasis + code: `console.log(\`built \${path.join(a, b)}\`);`, + }, + ], + invalid: [], + }); + }); + + test('valid: fs.writeFileSync with path join as first arg — real FS path, not content', () => { + ruleTester.run('normalize-path-in-content', normalizePathInContent, { + valid: [ + { + // The ENTIRE template is an arg to fs.writeFileSync → suppressed + // as a real filesystem path argument, not markdown content + code: `fs.writeFileSync(\`\${path.join(a, b)}/foo\`, data);`, + }, + ], + invalid: [], + }); + }); + + test('valid: new Error with path — diagnostic, not content', () => { + ruleTester.run('normalize-path-in-content', normalizePathInContent, { + valid: [ + { + // Diagnostic / error message: not markdown content + code: `throw new Error(\`File not found: \${path.join(dir, file)}\`);`, + }, + ], + invalid: [], + }); + }); + + test('valid: template literal with no content markers does not flag path', () => { + ruleTester.run('normalize-path-in-content', normalizePathInContent, { + valid: [ + { + // No @, $HOME, ~/., .md, /gsd, etc. → not considered content + code: `const msg = \`Processing \${path.join(a, b)} now\`;`, + }, + ], + invalid: [], + }); + }); + + test('valid: String() + .replace() chain normalized before interpolation', () => { + ruleTester.run('normalize-path-in-content', normalizePathInContent, { + valid: [ + { + // The real fix used in runtime-artifact-conversion.cts line 2193 + code: `const posixTarget = String(resolvedTarget).replace(/\\\\/g, '/'); +const ref = \`@\${posixTarget}/.claude/x.md\`;`, + }, + ], + invalid: [], + }); + }); + + test('valid: resolveAgentDir result already POSIX-normalized via toPosixPath', () => { + ruleTester.run('normalize-path-in-content', normalizePathInContent, { + valid: [ + { + code: `const body = \`@\${toPosixPath(resolveAgentDir('opencode', { homedir: () => home }))}/agents/gsd.md\`;`, + }, + ], + invalid: [], + }); + }); + + test('valid: path used in require() — module load, not content', () => { + ruleTester.run('normalize-path-in-content', normalizePathInContent, { + valid: [ + { + // require() is a suppressed context + code: `const m = require(\`\${path.join(libDir, 'helpers')}\`);`, + }, + ], + invalid: [], + }); + }); + + // ── N1: path.basename exclusion ───────────────────────────────────────────── + // + // path.basename() returns only the final filename component (no directory + // separators), so it cannot leak backslashes into content regardless of OS. + // It is excluded from CONTENT_PATH_FNS for this rule. + + test('valid (N1): path.basename(p) in a template with @~/ reference marker — no flag because basename cannot contain separators', () => { + ruleTester.run('normalize-path-in-content', normalizePathInContent, { + valid: [ + { + // path.basename returns a filename with no directory separators; + // interpolating it into @~/ reference content is safe on all platforms. + // Uses '@~/' marker so the content heuristic fires, then confirms no + // violation because path.basename is excluded from CONTENT_PATH_FNS. + code: "const label = `@~/${path.basename(outputPath)}/x.md`;", + }, + ], + invalid: [], + }); + }); + + test('valid (N1): path.basename(p) in a template with /commands/ marker — not flagged', () => { + ruleTester.run('normalize-path-in-content', normalizePathInContent, { + valid: [ + { + code: `const ref = \`/commands/\${path.basename(outputPath)}/help.md\`;`, + }, + ], + invalid: [], + }); + }); + + // ── N2: tightened content heuristic ───────────────────────────────────────── + // + // A bare `.md` token in plain prose (no @-ref, ~/., $HOME, config-dir, or + // artifact-path segment) must NOT qualify as "content". This test mirrors + // the false-positive from src/profile-output.cts:1205. + + test('valid (N2): path.basename(outputPath) in a status-message template with bare .md prose — not flagged', () => { + ruleTester.run('normalize-path-in-content', normalizePathInContent, { + valid: [ + { + // Mirrors the src/profile-output.cts:1205 false positive. The template + // contains "PROJECT.md / REQUIREMENTS.md" (bare prose), but the quasi + // after the expression does NOT start with '/' → shape (b) does not fire. + // And no @-ref / $HOME / ~/ markers → shape (a) does not fire. + code: `const msg = \`Left existing \${path.basename(outputPath)} untouched (no GSD markers found). Broad project context lives in PROJECT.md / REQUIREMENTS.md; pass --force to overwrite.\`;`, + }, + ], + invalid: [], + }); + }); + + test('valid (N2): path.join result in prose with bare .md mention — quasi after expr does not start with / → not flagged', () => { + ruleTester.run('normalize-path-in-content', normalizePathInContent, { + valid: [ + { + // path.join IS in CONTENT_PATH_FNS, but the quasi after the expression is + // ': see README.md for details.' which does NOT start with '/' → shape (b) + // does not fire. No @-ref / $HOME / ~/ markers either. + code: `const note = \`Generated \${path.join(dir, 'output')}: see README.md for details.\`;`, + }, + ], + invalid: [], + }); + }); + + // ── N4: canonical INVALID shapes still flag after N1/N2 changes ───────────── + + test('invalid (N4 confirm): @~/${path.join(home,".claude")}/x.md — still flagged after N1/N2/@-narrowing', () => { + ruleTester.run('normalize-path-in-content', normalizePathInContent, { + valid: [], + invalid: [ + { + // @~/ prefix marker in quasis → genuine @-reference content → path.join flagged. + // Confirms that narrowing '@' to '@~'/'@$'/'@/' leaves the canonical case working. + code: "const ref = `@~/${path.join(home, '.claude')}/x.md`;", + errors: [{ messageId: 'pathInContent' }], + }, + ], + }); + }); + + test('invalid (N4 confirm): ${computePathPrefix(t)}/commands/gsd/x.md — still flagged after N1/N2 via shape (b)', () => { + ruleTester.run('normalize-path-in-content', normalizePathInContent, { + valid: [], + invalid: [ + { + // Shape (b): quasi[1] = '/commands/gsd/x.md' starts with '/' and ends with '.md' + // → QUASI_MD_JSON_RE matches → flagged. (/commands/ is no longer in CONTENT_MARKERS + // but shape (b) per-expression check catches this case.) + code: `const body = \`\${computePathPrefix(t)}/commands/gsd/x.md\`;`, + errors: [{ messageId: 'pathInContent' }], + }, + ], + }); + }); + + // ── W2: bare Error(...) / TypeError(...) suppression ───────────────────────── + // + // A bare Error(...) call (CallExpression, not NewExpression) with a path inside + // must be suppressed — it is a diagnostic, not content. + + test('valid (W2): throw Error(`...${path.join(...)}`) — Error() is diagnostic; also /.claude is no longer a content marker', () => { + ruleTester.run('normalize-path-in-content', normalizePathInContent, { + valid: [ + { + // W2: bare Error() call (CallExpression). Additionally, /.claude is no + // longer in CONTENT_MARKERS (removed to eliminate diagnostic FPs), so this + // template has no content markers at all — not flagged on either ground. + code: "throw Error(`Cannot install to /.claude: ${path.join(a, b)}`);", + }, + ], + invalid: [], + }); + }); + + test('valid (W2): throw TypeError(`...${path.join(...)}`) — TypeError() is suppressed; also no content markers', () => { + ruleTester.run('normalize-path-in-content', normalizePathInContent, { + valid: [ + { + // W2: /.claude no longer a content marker; also in an Error() call context. + code: "throw TypeError(`Bad path /.claude: ${path.join(a, b)}`);", + }, + ], + invalid: [], + }); + }); + + // ── W3: chained and right-deep concatenation with nested BinaryExpression ────── + // + // The BinaryExpression check recursively scans the FULL concat tree for both: + // (1) a content marker anywhere in the tree (via isContentString recursion) + // (2) an unnormalized path call anywhere in the non-content subtree + // (via scanAndReport recursion — the "right-deep FN" fix) + // + // Cases: + // '@~/' + name + path.join(home, '.claude') + // → parse tree: ('@~/' + name) + path.join(...) + // → outer left is BinaryExpression → isContentString descends → finds '@~/' + // → outer right is path.join → isUnnormalizedPathExpression → flagged + // + // '@~/' + (name + path.join(home, '.claude')) + // → parse tree: '@~/' + (name + path.join(...)) + // → left is '@~/' → content marker found + // → right is BinaryExpression; scanAndReport descends → finds path.join → flagged + + test('invalid (W3): "@~/" + name + path.join(home, ".claude") — chained concat flagged via recursive scan', () => { + ruleTester.run('normalize-path-in-content', normalizePathInContent, { + valid: [], + invalid: [ + { + // W3 left-deep: left side is '@~/' + name (nested BinaryExpression). + // Without recursive descent, the marker '@~/' in the inner literal + // is invisible to the outer + node and the violation is missed. + code: "const s = '@~/' + name + path.join(home, '.claude');", + errors: [{ messageId: 'pathInContent' }], + }, + ], + }); + }); + + test('invalid (W3-right-deep): "@~/" + (name + path.join(home, ".claude")) — right-deep path call flagged', () => { + ruleTester.run('normalize-path-in-content', normalizePathInContent, { + valid: [], + invalid: [ + { + // W3 right-deep: content marker is in the left literal '@~/' and the path + // call is nested inside the right-side BinaryExpression (name + path.join). + // The recursive scanAndReport must descend into the right subtree to find + // path.join and report it. + code: "const s = '@~/' + (name + path.join(home, '.claude'));", + errors: [{ messageId: 'pathInContent' }], + }, + ], + }); + }); + + test('valid (W3): "log " + path.join(a, b) — no content marker in any sub-literal → not flagged', () => { + ruleTester.run('normalize-path-in-content', normalizePathInContent, { + valid: [ + { + // No content marker anywhere in the concat tree → not flagged. + code: "const s = 'log ' + path.join(a, b);", + }, + ], + invalid: [], + }); + }); + + // ── '@' narrowing precision ──────────────────────────────────────────────────── + // + // A bare '@' that is not @~, @$, or @/ must NOT trigger the content heuristic + // (e.g. email addresses, @author attributions in comments / strings). + + test('valid (@-narrowing): bare "@" in string (e.g. email) is not an @-reference marker', () => { + ruleTester.run('normalize-path-in-content', normalizePathInContent, { + valid: [ + { + // The string contains '@' but no @~, @$, @/ and no ~/., $HOME markers. + // Bare '@' is not in CONTENT_MARKERS (only @~, @$, @/ are). + // Also: quasi after expression is ';' — no /…\.md shape (b) match. + code: "const msg = `Contact author@example.com for ${path.join(a, b)}`;", + }, + ], + invalid: [], + }); + }); + + test('valid (@-narrowing): @~/${path.join(home, ".claude")}/x.md WITH normalization — not flagged', () => { + ruleTester.run('normalize-path-in-content', normalizePathInContent, { + valid: [ + { + // Confirms that after @-narrowing, a proper @~/ reference WITH POSIX + // normalization is still correctly NOT flagged. + code: "const ref = `@~/${String(path.join(home, '.claude')).replace(/\\\\/g, '/')}/x.md`;", + }, + ], + invalid: [], + }); + }); +}); + +// ── TS-parser RuleTester ─────────────────────────────────────────────────────── +// +// Run representative fixtures through the @typescript-eslint/parser to confirm +// the rule works under the production parser (used on src/**/*.cts). This +// catches any AST-shape differences between espree and ts-estree. + +const { RuleTester: TSRuleTester } = require('eslint'); +const tsParser = require('@typescript-eslint/parser'); + +const tsRuleTester = new TSRuleTester({ + languageOptions: { + parser: tsParser, + ecmaVersion: 2022, + sourceType: 'commonjs', + }, +}); + +describe('normalize-path-in-content — @typescript-eslint/parser (TS syntax fixtures)', () => { + test('TS-parser INVALID: @~/${path.join(home as string, ".claude")}/x.md — flagged under TS parser', () => { + tsRuleTester.run('normalize-path-in-content', normalizePathInContent, { + valid: [], + invalid: [ + { + // TS-specific syntax: `home as string` type assertion inside path.join arg. + // The expression inside ${ } is still path.join (a CallExpression) → flagged. + code: "const ref = `@~/${path.join(home as string, '.claude')}/x.md`;", + errors: [{ messageId: 'pathInContent' }], + }, + ], + }); + }); + + test('TS-parser VALID: @~/${toPosixPath(path.join(home as string, ".claude"))}/x.md — not flagged', () => { + tsRuleTester.run('normalize-path-in-content', normalizePathInContent, { + valid: [ + { + // Same TS syntax but POSIX-normalized via toPosixPath — must NOT be flagged. + code: "const ref = `@~/${toPosixPath(path.join(home as string, '.claude'))}/x.md`;", + }, + ], + invalid: [], + }); + }); +}); diff --git a/tests/observability/logger.test.cjs b/tests/observability/logger.test.cjs index 2abd79b70..74e6c075c 100644 --- a/tests/observability/logger.test.cjs +++ b/tests/observability/logger.test.cjs @@ -150,7 +150,7 @@ describe('createDefaultLogger — stderr on error', () => { const stderrOutput = captureStderr(() => logger.onEvent(errEvent)); // Must be exactly one non-empty line - const lines = stderrOutput.split('\n').filter(l => l.trim().length > 0); + const lines = stderrOutput.split(/\r?\n/).filter(l => l.trim().length > 0); assert.equal(lines.length, 1, `expected 1 line, got ${lines.length}: ${stderrOutput}`); }); @@ -259,7 +259,7 @@ describe('createDefaultLogger — audit file', () => { const auditPath = path.join(tmpDir, '.planning', '.gsd-trace.jsonl'); const content = fs.readFileSync(auditPath, 'utf8'); - const lines = content.split('\n').filter(l => l.trim().length > 0); + const lines = content.split(/\r?\n/).filter(l => l.trim().length > 0); assert.equal(lines.length, 2, `expected 2 lines, got ${lines.length}`); const parsed0 = JSON.parse(lines[0]); @@ -279,7 +279,7 @@ describe('createDefaultLogger — audit file', () => { logger2.onEvent(makeOkEvent({ traceId: 'second' })); const content = fs.readFileSync(auditPath, 'utf8'); - const lines = content.split('\n').filter(l => l.trim().length > 0); + const lines = content.split(/\r?\n/).filter(l => l.trim().length > 0); assert.equal(lines.length, 2, 'both events must appear (append-only)'); assert.equal(JSON.parse(lines[0]).traceId, 'first'); assert.equal(JSON.parse(lines[1]).traceId, 'second'); @@ -293,7 +293,7 @@ describe('createDefaultLogger — audit file', () => { const auditPath = path.join(tmpDir, '.planning', '.gsd-trace.jsonl'); const content = fs.readFileSync(auditPath, 'utf8'); - const lines = content.split('\n').filter(l => l.trim().length > 0); + const lines = content.split(/\r?\n/).filter(l => l.trim().length > 0); assert.equal(lines.length, 2); const traceIds = lines.map(l => JSON.parse(l).traceId); diff --git a/tests/package-legitimacy-gate.test.cjs b/tests/package-legitimacy-gate.test.cjs index a6af9a3a5..5d0a3e051 100644 --- a/tests/package-legitimacy-gate.test.cjs +++ b/tests/package-legitimacy-gate.test.cjs @@ -18,7 +18,7 @@ const PLANNER = path.join(AGENTS, 'gsd-planner.md'); const EXECUTOR = path.join(AGENTS, 'gsd-executor.md'); function parseSections(md) { - const lines = md.split('\n'); + const lines = md.split(/\r?\n/); const sections = []; let current = { heading: '__preamble__', body: [] }; let inFence = false; @@ -39,7 +39,7 @@ function parseSections(md) { function extractCodeBlocks(text) { const blocks = []; - const lines = text.split('\n'); + const lines = text.split(/\r?\n/); let inside = false; let buf = []; @@ -59,7 +59,7 @@ function extractCodeBlocks(text) { } function extractResearchTemplate(content) { - const lines = content.split('\n'); + const lines = content.split(/\r?\n/); let inside = false; let isMarkdownFence = false; let buf = []; @@ -182,7 +182,7 @@ function readModel(filePath) { const text = fs.readFileSync(filePath, 'utf-8'); return { text, - lines: text.split('\n'), + lines: text.split(/\r?\n/), sections: parseSections(text), codeBlocks: extractCodeBlocks(text), }; @@ -388,7 +388,7 @@ describe('gsd-planner.md — supply-chain row in threat_model template', () => { }); test('threat_model template includes supply-chain row with mitigate disposition', () => { - const tables = parseMarkdownTables(threatModelBlock.split('\n')); + const tables = parseMarkdownTables(threatModelBlock.split(/\r?\n/)); const strideTable = tables.find((table) => table.headers.includes('Threat ID')); assert.ok(strideTable, 'threat_model must include STRIDE threat register table'); diff --git a/tests/package-name-single-source.test.cjs b/tests/package-name-single-source.test.cjs index 8d3f5ba21..59b6b1714 100644 --- a/tests/package-name-single-source.test.cjs +++ b/tests/package-name-single-source.test.cjs @@ -83,7 +83,7 @@ test('no hardcoded @opengsd/gsd-core literals in runtime non-comment code lines if (path.resolve(file) === path.resolve(IDENTITY_MODULE)) continue; const content = fs.readFileSync(file, 'utf-8'); - const lines = content.split('\n'); + const lines = content.split(/\r?\n/); for (let i = 0; i < lines.length; i++) { const line = lines[i]; diff --git a/tests/phase.test.cjs b/tests/phase.test.cjs index fc70420fc..62c4c90c7 100644 --- a/tests/phase.test.cjs +++ b/tests/phase.test.cjs @@ -2826,7 +2826,7 @@ describe('phase complete command', () => { assert.ok(result.success, `Command failed: ${result.error}`); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); - const rowMatch = roadmap.match(/^\|[^\n]*1\. Foundation[^\n]*$/m); + const rowMatch = roadmap.match(/^\|[^\r\n]*1\. Foundation[^\r\n]*$/m); assert.ok(rowMatch, 'table row should exist'); const cells = rowMatch[0].split('|').slice(1, -1).map(c => c.trim()); assert.strictEqual(cells.length, 5, 'should have 5 columns'); @@ -2897,7 +2897,7 @@ describe('phase complete command', () => { assert.ok(result.success, `Command failed: ${result.error}`); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); - const rowMatch = roadmap.match(/^\|[^\n]*1\. Foundation[^\n]*$/m); + const rowMatch = roadmap.match(/^\|[^\r\n]*1\. Foundation[^\r\n]*$/m); assert.ok(rowMatch, 'table row should exist'); const cells = rowMatch[0].split('|').slice(1, -1).map(c => c.trim()); assert.strictEqual(cells.length, 4, 'should have 4 columns'); @@ -2937,7 +2937,7 @@ describe('phase complete command', () => { assert.ok(result.success, `Command failed: ${result.error}`); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); - const rowMatch = roadmap.match(/^\|[^\n]*1\. Foundation[^\n]*$/m); + const rowMatch = roadmap.match(/^\|[^\r\n]*1\. Foundation[^\r\n]*$/m); assert.ok(rowMatch, 'table row should exist'); const cells = rowMatch[0].split('|').slice(1, -1).map(c => c.trim()); assert.strictEqual(cells.length, 5, 'should have 5 columns'); @@ -4223,12 +4223,12 @@ describe('bug-3287 — init plan-phase exposes expected_phase_dir with project_c } function containsBareTemplateMkdir(content) { - return /mkdir[^`\n]*\.planning\/phases\/\{[A-Z0-9]+\}-\{/.test(content); + return /mkdir[^`\r\n]*\.planning\/phases\/\{[A-Z0-9]+\}-\{/.test(content); } function containsBareShellVarMkdir(content) { - return /mkdir[^`\n]*\.planning\/phases\/"\$\{(?:NEXT|NN|PHASE)[^}]*\}-/.test(content) - || /mkdir[^`\n]*\.planning\/phases\/\$\{(?:NEXT|NN|PHASE)[^}]*\}-/.test(content); + return /mkdir[^`\r\n]*\.planning\/phases\/"\$\{(?:NEXT|NN|PHASE)[^}]*\}-/.test(content) + || /mkdir[^`\r\n]*\.planning\/phases\/\$\{(?:NEXT|NN|PHASE)[^}]*\}-/.test(content); } describe('bug-3298 — plan-milestone-gaps.md must not construct bare {NN}-{name} phase dirs', () => { diff --git a/tests/phase6-capstone-conformance.test.cjs b/tests/phase6-capstone-conformance.test.cjs index 4f3f580d1..0e5b32bdf 100644 --- a/tests/phase6-capstone-conformance.test.cjs +++ b/tests/phase6-capstone-conformance.test.cjs @@ -244,7 +244,7 @@ describe('ADR-857 phase 6 — capabilities must not bake install paths into the test('generated capability-registry.cjs contains no ~/.claude install path', () => { const reg = fs.readFileSync(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'capability-registry.cjs'), 'utf8'); - const leakLines = reg.split('\n').map((l, i) => [i + 1, l]).filter(([, l]) => LEAK.test(l)).map(([n]) => n); + const leakLines = reg.split(/\r?\n/).map((l, i) => [i + 1, l]).filter(([, l]) => LEAK.test(l)).map(([n]) => n); assert.deepEqual(leakLines, [], `capability-registry.cjs leaks ~/.claude install paths at line(s) ${leakLines.join(', ')} — the registry is copied verbatim to non-Claude runtimes (only workflow .md files are path-converted at install). Make the source capability fragment path-free.`); }); diff --git a/tests/plan-review-convergence.test.cjs b/tests/plan-review-convergence.test.cjs index d10fd6b14..1260a3016 100644 --- a/tests/plan-review-convergence.test.cjs +++ b/tests/plan-review-convergence.test.cjs @@ -177,7 +177,7 @@ describe('plan-review-convergence workflow: config gate (#2306-v2)', () => { test('workflow defaults config key to false (opt-in, not opt-out)', () => { // The config-get call must default to false, not true - const configGetMatch = workflow.match(/config-get\s+workflow\.plan_review_convergence[^\n]*/); + const configGetMatch = workflow.match(/config-get\s+workflow\.plan_review_convergence[^\r\n]*/); assert.ok( configGetMatch, 'workflow must read workflow.plan_review_convergence via config-get' @@ -546,7 +546,7 @@ describe('plan-review-convergence CONFIGURATION.md documentation (#2306-v2)', () }); test('CONFIGURATION.md entry documents disabled-by-default behavior', () => { - const row = configDoc.match(/workflow\.plan_review_convergence[^\n]*/); + const row = configDoc.match(/workflow\.plan_review_convergence[^\r\n]*/); assert.ok(row, 'workflow.plan_review_convergence row must exist in CONFIGURATION.md'); assert.ok( row[0].includes('false') || row[0].includes('disabled'), @@ -724,7 +724,7 @@ describe('plan-review-convergence workflow: source-grounding reviewer pass (#22) // ── Severity mappings: AMBIGUOUS→MEDIUM and UNCHECKABLE→INFO must appear // on the SAME line inside the section, not just anywhere in the file ──── - const severityLine = section.split('\n').find((line) => + const severityLine = section.split(/\r?\n/).find((line) => line.includes('AMBIGUOUS') && line.includes('MEDIUM') && line.includes('UNCHECKABLE') && line.includes('INFO') ); @@ -856,7 +856,7 @@ describe('plan-review-convergence workflow: inline plan-phase dispatch (#936)', /Skill\(\s*skill=['"]gsd-plan-phase['"]/.test(b.blockText) ); assert.deepStrictEqual( - wrapping.map((b) => b.blockText.slice(0, 80).replace(/\n/g, '\\n')), + wrapping.map((b) => b.blockText.slice(0, 80).replace(/\r?\n/g, '\\n')), [], 'Initial planning must NOT wrap gsd-plan-phase inside Agent() — run it inline so ' + 'it can spawn gsd-planner/gsd-plan-checker at depth 1. See: bug #936' @@ -871,7 +871,7 @@ describe('plan-review-convergence workflow: inline plan-phase dispatch (#936)', /--reviews/.test(b.blockText) ); assert.deepStrictEqual( - wrapping.map((b) => b.blockText.slice(0, 80).replace(/\n/g, '\\n')), + wrapping.map((b) => b.blockText.slice(0, 80).replace(/\r?\n/g, '\\n')), [], 'Replan step must NOT wrap gsd-plan-phase inside Agent() — the replan loop can ' + 'never produce a plan on Claude Code when plan-phase is at depth 1. See: bug #936' diff --git a/tests/platform-guard.unit.test.cjs b/tests/platform-guard.unit.test.cjs new file mode 100644 index 000000000..837d4cd0e --- /dev/null +++ b/tests/platform-guard.unit.test.cjs @@ -0,0 +1,381 @@ +'use strict'; + +/** + * platform-guard.unit.test.cjs + * + * Unit tests for eslint-rules/lib/platform-guard.cjs. + * Verifies classifyPlatformTest and isWindowsExcludedNode shapes + * using espree to parse code snippets into ASTs. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const espree = require('espree'); +const { Linter } = require('eslint'); + +const { classifyPlatformTest, isWindowsExcludedNode } = require('../eslint-rules/lib/platform-guard.cjs'); + +const PARSE_OPTIONS = { + ecmaVersion: 2022, + sourceType: 'script', + range: true, + loc: true, + tokens: true, + comment: true, +}; + +function parse(code) { + return espree.parse(code, PARSE_OPTIONS); +} + +// ─── classifyPlatformTest ───────────────────────────────────────────────────── + +describe('classifyPlatformTest', () => { + test('process.platform === "win32" → windows', () => { + const ast = parse(`process.platform === 'win32'`); + const node = ast.body[0].expression; + assert.strictEqual(classifyPlatformTest(node), 'windows'); + }); + + test('"win32" === process.platform (reversed) → windows', () => { + const ast = parse(`'win32' === process.platform`); + const node = ast.body[0].expression; + assert.strictEqual(classifyPlatformTest(node), 'windows'); + }); + + test('process.platform !== "win32" → not-windows', () => { + const ast = parse(`process.platform !== 'win32'`); + const node = ast.body[0].expression; + assert.strictEqual(classifyPlatformTest(node), 'not-windows'); + }); + + test('os.platform() === "win32" → windows', () => { + const ast = parse(`os.platform() === 'win32'`); + const node = ast.body[0].expression; + assert.strictEqual(classifyPlatformTest(node), 'windows'); + }); + + test('os.platform() !== "win32" → not-windows', () => { + const ast = parse(`os.platform() !== 'win32'`); + const node = ast.body[0].expression; + assert.strictEqual(classifyPlatformTest(node), 'not-windows'); + }); + + test('isWindows identifier → windows', () => { + const ast = parse(`isWindows`); + const node = ast.body[0].expression; + assert.strictEqual(classifyPlatformTest(node), 'windows'); + }); + + test('IS_WINDOWS identifier → windows', () => { + const ast = parse(`IS_WINDOWS`); + const node = ast.body[0].expression; + assert.strictEqual(classifyPlatformTest(node), 'windows'); + }); + + test('isWin identifier → windows', () => { + const ast = parse(`isWin`); + const node = ast.body[0].expression; + assert.strictEqual(classifyPlatformTest(node), 'windows'); + }); + + test('onWindows identifier → windows', () => { + const ast = parse(`onWindows`); + const node = ast.body[0].expression; + assert.strictEqual(classifyPlatformTest(node), 'windows'); + }); + + test('!isWindows → not-windows', () => { + const ast = parse(`!isWindows`); + const node = ast.body[0].expression; + assert.strictEqual(classifyPlatformTest(node), 'not-windows'); + }); + + test('unrelated expression → null', () => { + const ast = parse(`x === 'linux'`); + const node = ast.body[0].expression; + assert.strictEqual(classifyPlatformTest(node), null); + }); + + test('bare identifier "result" → null', () => { + const ast = parse(`result`); + const node = ast.body[0].expression; + assert.strictEqual(classifyPlatformTest(node), null); + }); +}); + +// ─── isWindowsExcludedNode via Linter ──────────────────────────────────────── +// We use the Linter + a custom rule to get real sourceCode with ancestors. + +/** + * Build a mini rule that collects data about CallExpression nodes named + * "assert.equal" and checks isWindowsExcludedNode on them. + */ +function buildCollectorRule() { + return { + create(context) { + const sourceCode = context.sourceCode; + const results = []; + return { + CallExpression(node) { + if ( + node.callee.type === 'MemberExpression' && + node.callee.object.name === 'assert' && + node.callee.property.name === 'equal' + ) { + results.push(isWindowsExcludedNode(node, sourceCode)); + } + }, + 'Program:exit'() { + context.report({ node: sourceCode.ast, messageId: 'result', data: { results: JSON.stringify(results) } }); + }, + }; + }, + meta: { type: 'suggestion', schema: [], messages: { result: '{{results}}' } }, + }; +} + +function runCollector(code) { + const linter = new Linter({ configType: 'flat' }); + const messages = linter.verify( + code, + [{ plugins: { t: { rules: { collector: buildCollectorRule() } } }, rules: { 't/collector': 'warn' }, languageOptions: { ecmaVersion: 2022, sourceType: 'script' } }], + { filename: 'tests/x.test.cjs' } + ); + // The rule emits exactly one message (Program:exit) with results as JSON + const msg = messages.find(m => m.ruleId === 't/collector'); + if (!msg) return []; + return JSON.parse(msg.message); +} + +describe('isWindowsExcludedNode — if (!windows) { assert } shape', () => { + test('assert inside if (process.platform !== "win32") block → excluded=true', () => { + const code = ` + if (process.platform !== 'win32') { + assert.equal(path.join(a, b), '/x/y'); + } + `; + const results = runCollector(code); + assert.deepStrictEqual(results, [true]); + }); + + test('assert inside else of if (process.platform === "win32") block → excluded=true', () => { + const code = ` + if (process.platform === 'win32') { + doWindows(); + } else { + assert.equal(path.join(a, b), '/x/y'); + } + `; + const results = runCollector(code); + assert.deepStrictEqual(results, [true]); + }); + + test('assert NOT inside any guard → excluded=false', () => { + const code = `assert.equal(path.join(a, b), '/x/y');`; + const results = runCollector(code); + assert.deepStrictEqual(results, [false]); + }); +}); + +describe('isWindowsExcludedNode — early-return guard shape', () => { + test('if (process.platform === "win32") return; assert.equal(...) → excluded=true', () => { + const code = ` + function test() { + if (process.platform === 'win32') return; + assert.equal(path.join(a, b), '/x/y'); + } + `; + const results = runCollector(code); + assert.deepStrictEqual(results, [true]); + }); + + test('if (process.platform === "win32") return t.skip(); assert.equal → excluded=true', () => { + const code = ` + function test(t) { + if (process.platform === 'win32') return t.skip('no windows'); + assert.equal(path.join(a, b), '/x/y'); + } + `; + const results = runCollector(code); + assert.deepStrictEqual(results, [true]); + }); + + test('guard appears AFTER the assert → excluded=false', () => { + const code = ` + function test() { + assert.equal(path.join(a, b), '/x/y'); + if (process.platform === 'win32') return; + } + `; + const results = runCollector(code); + assert.deepStrictEqual(results, [false]); + }); +}); + +describe('isWindowsExcludedNode — hoisted isWindows guard shape', () => { + test('const isWindows = process.platform === "win32"; if (!isWindows) assert.equal → excluded=true', () => { + const code = ` + const isWindows = process.platform === 'win32'; + if (!isWindows) assert.equal(path.join(a, b), '/x/y'); + `; + const results = runCollector(code); + assert.deepStrictEqual(results, [true]); + }); + + test('const isWindows = …; if (isWindows) {} else { assert.equal } → excluded=true', () => { + const code = ` + const isWindows = process.platform === 'win32'; + if (isWindows) { doWindowsThing(); } else { assert.equal(path.join(a,b), '/x/y'); } + `; + const results = runCollector(code); + assert.deepStrictEqual(results, [true]); + }); +}); + +describe('isWindowsExcludedNode — arbitrary-named hoisted boolean (real hoisting)', () => { + test('const winFlag = process.platform === "win32"; if (!winFlag) assert.equal → excluded=true', () => { + const code = ` + function test() { + const winFlag = process.platform === 'win32'; + if (!winFlag) { + assert.equal(path.join(a, b), '/x/y'); + } + } + `; + const results = runCollector(code); + assert.deepStrictEqual(results, [true]); + }); + + test('const winFlag = process.platform === "win32"; if (winFlag) return; assert.equal → excluded=true', () => { + const code = ` + function test() { + const winFlag = process.platform === 'win32'; + if (winFlag) return; + assert.equal(path.join(a, b), '/x/y'); + } + `; + const results = runCollector(code); + assert.deepStrictEqual(results, [true]); + }); + + test('unrelated variable used as guard is NOT excluded', () => { + const code = ` + function test() { + const debugMode = true; + if (!debugMode) { + assert.equal(path.join(a, b), '/x/y'); + } + } + `; + const results = runCollector(code); + assert.deepStrictEqual(results, [false]); + }); + + test('winFlag declared AFTER the assert is NOT a guard → excluded=false', () => { + const code = ` + function test() { + assert.equal(path.join(a, b), '/x/y'); + const winFlag = process.platform === 'win32'; + if (!winFlag) { doSomething(); } + } + `; + const results = runCollector(code); + assert.deepStrictEqual(results, [false]); + }); +}); + +// ─── C2: early-return guard must climb ancestor blocks ──────────────────────── + +describe('C2 — early-return guard climbs ancestor blocks', () => { + test('C2: early-return in outer block before nested if-block → excluded=true', () => { + // The guard is in the function body; assert.equal is inside a nested if-block. + // Before the fix, _hasEarlyWindowsReturnBefore only checked the innermost block. + const code = ` + function test() { + if (process.platform === 'win32') return; + if (cond) { + assert.equal(path.join(a, b), '/x/y'); + } + } + `; + const results = runCollector(code); + assert.deepStrictEqual(results, [true]); + }); + + test('C2: no early-return at all → excluded=false', () => { + const code = ` + function test() { + if (cond) { + assert.equal(path.join(a, b), '/x/y'); + } + } + `; + const results = runCollector(code); + assert.deepStrictEqual(results, [false]); + }); +}); + +// ─── C3: binding-aware identifier classification ────────────────────────────── + +describe('C3 — binding-aware identifier classification', () => { + test('C3 case 1: const isWindows = false; if (!isWindows) assert.equal → excluded=false (FLAGGED)', () => { + // isWindows is in WINDOWS_BOOL_NAMES but its binding is `false`, not a platform test. + const code = ` + function test() { + const isWindows = false; + if (!isWindows) { + assert.equal(path.join(a, b), '/x/y'); + } + } + `; + const results = runCollector(code); + assert.deepStrictEqual(results, [false], 'const isWindows = false should not be treated as a platform guard'); + }); + + test('C3 case 2: let w = platform===win32; w = false; if (!w) assert.equal → excluded=false (FLAGGED)', () => { + // w starts as a platform test but is reassigned — should NOT be trusted. + const code = ` + function test() { + let w = process.platform === 'win32'; + w = false; + if (!w) { + assert.equal(path.join(a, b), '/x/y'); + } + } + `; + const results = runCollector(code); + assert.deepStrictEqual(results, [false], 'reassigned variable should not be treated as a platform guard'); + }); + + test('C3 case 3: inner shadow const w = false wins over outer const w = platform test → excluded=false (FLAGGED)', () => { + // The inner const w = false shadows the outer const w = platform test. + const code = ` + function test() { + const w = process.platform === 'win32'; + { + const w = false; + if (!w) { + assert.equal(path.join(a, b), '/x/y'); + } + } + } + `; + const results = runCollector(code); + assert.deepStrictEqual(results, [false], 'inner shadow (w=false) should win over outer platform test'); + }); + + test('C3 case 4: const w = platform test; if (!w) assert.equal → excluded=true (genuine guard)', () => { + // The canonical case — should still work. + const code = ` + function test() { + const w = process.platform === 'win32'; + if (!w) { + assert.equal(path.join(a, b), '/x/y'); + } + } + `; + const results = runCollector(code); + assert.deepStrictEqual(results, [true], 'genuine platform guard should suppress the report'); + }); +}); diff --git a/tests/policy-138-nyquist-config-default.test.cjs b/tests/policy-138-nyquist-config-default.test.cjs index 827cd8f7e..0665e1d04 100644 --- a/tests/policy-138-nyquist-config-default.test.cjs +++ b/tests/policy-138-nyquist-config-default.test.cjs @@ -35,7 +35,7 @@ function assertNyquistCapabilityGate(name) { function findNyquistConfigLine(filePath) { const content = fs.readFileSync(filePath, 'utf8'); - const lines = content.split('\n'); + const lines = content.split(/\r?\n/); for (let i = 0; i < lines.length; i++) { if (lines[i].includes('config-get workflow.nyquist_validation')) { return { lineNumber: i + 1, line: lines[i] }; diff --git a/tests/policy-release-no-npm-self-upgrade.test.cjs b/tests/policy-release-no-npm-self-upgrade.test.cjs index 02794b0b8..b08a49801 100644 --- a/tests/policy-release-no-npm-self-upgrade.test.cjs +++ b/tests/policy-release-no-npm-self-upgrade.test.cjs @@ -14,14 +14,14 @@ const WORKFLOWS_DIR = path.join(REPO_ROOT, '.github', 'workflows'); // Matches: npm install -g npm@..., npm i -g npm, npm install --global npm@11, etc. // Does NOT match: npm ci, npm install (no -g / --global followed by npm) -const NPM_SELF_UPGRADE_RE = /\bnpm\s+(install|i)\s+(-g|--global)\b[^\n]*\bnpm(@|\b)/; +const NPM_SELF_UPGRADE_RE = /\bnpm\s+(install|i)\s+(-g|--global)\b[^\r\n]*\bnpm(@|\b)/; describe('policy: no runtime npm self-upgrade in release lanes (#318)', () => { const releaseFile = path.join(WORKFLOWS_DIR, 'release.yml'); test('release.yml must not contain a runtime global npm self-upgrade step', () => { const content = fs.readFileSync(releaseFile, 'utf8'); - const lines = content.split('\n'); + const lines = content.split(/\r?\n/); const violations = lines .map((line, idx) => ({ line, lineNo: idx + 1 })) .filter(({ line }) => NPM_SELF_UPGRADE_RE.test(line)); diff --git a/tests/portability-rule-disable-ban.test.cjs b/tests/portability-rule-disable-ban.test.cjs new file mode 100644 index 000000000..82652cce8 --- /dev/null +++ b/tests/portability-rule-disable-ban.test.cjs @@ -0,0 +1,290 @@ +'use strict'; + +/** + * portability-rule-disable-ban.test.cjs + * + * Out-of-band disable-ban scan (ADR-1703). + * + * ESLint inline suppression of portability rules is banned. This test runs + * OUTSIDE ESLint so it cannot itself be eslint-disabled. + * + * PROTECTED_RULES grows as later phases add rules. Each new portability rule + * in the `local/` namespace should be appended to this list. + * + * Hard-fails on: + * (a) Any `eslint-disable*` comment that NAMES a protected portability rule. + * (b) Any BLANKET `eslint-disable*` comment (no rule list) — these suppress + * every rule including the protected ones. + * + * NOTE: This file itself is excluded from the scan by absolute path. It + * references the disable keyword only inside regex/string data structures to + * avoid being detected as a real directive. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const espree = require('espree'); +const tsEstree = require('@typescript-eslint/typescript-estree'); +const { globSync } = require('glob'); + +// ── Protected portability rules (grows with each ADR-1703 phase) ────────────── +const PROTECTED_RULES = [ + 'no-path-literal-in-assert', + 'no-posix-mode-bit-assert', + 'no-unguarded-nonportable-exec', + // ADR-1703 Phase 4 rules (issue #1726) + 'no-crlf-fragile-split', + 'no-hardcoded-tmp', + 'no-bare-npm-exec', + 'require-userprofile-with-home', + // ADR-1703 Phase 5 rule (issue #1733) — applies to src/**/*.cts (production sources) + 'normalize-path-in-content', + // ADR-1703 Phase 6 rule (issue #1740) — applies to src/**/*.cts AND the build/install + // surface (bin/install.js, scripts/build-hooks.js) brought under lint by the glob expansion + 'require-fs-op-fallback', +]; + +// ── Detect disable directives via the comment text ─────────────────────────── + +// The three directive forms ESLint recognises (built as concatenated strings so +// this source file contains NO real disable directive of its own). +const D = 'eslint-' + 'disable'; +const DN = 'eslint-' + 'disable-next-line'; +const DL = 'eslint-' + 'disable-line'; +const DISABLE_PREFIXES = [DN, DL, D]; // longest first so prefix-match is greedy + +/** + * Classify a comment node. Returns: + * 'blanket' — a disable with NO rule list (suppresses everything) + * 'named' — a disable that lists at least one protected portability rule + * null — not a disable directive, or a non-portability named disable + */ +function classifyComment(commentValue) { + const txt = commentValue.trim(); + for (const prefix of DISABLE_PREFIXES) { + if (txt.startsWith(prefix)) { + // Text after the directive keyword + const rest = txt.slice(prefix.length).trim(); + // Blanket: nothing after the keyword, or only a prose comment (starts with --) + if (!rest || rest.startsWith('--')) { + return 'blanket'; + } + // Named: rest is a comma-separated rule list (possibly with -- prose) + const ruleList = rest.split('--')[0]; // strip trailing prose + const rules = ruleList.split(',').map(r => r.trim()).filter(Boolean); + for (const rule of rules) { + for (const protected_ of PROTECTED_RULES) { + if (rule === 'local/' + protected_ || rule === protected_) { + return 'named'; + } + } + } + return null; // named disable but not for a protected rule + } + } + return null; +} + +// ── Collect scanned files ───────────────────────────────────────────────────── +// +// The disable-ban covers: +// - tests/**/*.test.cjs — test sources (phase 1–4 scope) +// - src/**/*.cts — production TypeScript sources (extended in phase 5 to +// protect normalize-path-in-content, which applies to +// src/**/*.cts; an eslint-disable there would bypass the +// production rule entirely) +// - bin/install.js + scripts/build-hooks.js — the ADR-1703 Phase 6 glob expansion +// surface (DEFECT.WINDOWS-FS-OPS); an eslint-disable in the +// generated installer or build-side atomic-replace helper +// would bypass require-fs-op-fallback / normalize-path-in-content + +const SELF_ABS = __filename; + +function collectTestFiles() { + const root = path.join(__dirname, '..'); + const testFiles = globSync('tests/**/*.test.cjs', { cwd: root }) + .map(rel => path.join(root, rel)) + .filter(absPath => absPath !== SELF_ABS); + const srcFiles = globSync('src/**/*.cts', { cwd: root }) + .map(rel => path.join(root, rel)); + // ADR-1703 Phase 6: the two production portability-rule surfaces outside src/ + tests/. + const prodExtra = [ + path.join(root, 'bin', 'install.js'), + path.join(root, 'scripts', 'build-hooks.js'), + ].filter(absPath => fs.existsSync(absPath)); + return [...testFiles, ...srcFiles, ...prodExtra]; +} + +// ── Scan ────────────────────────────────────────────────────────────────────── + +function scanFile(absPath) { + let src; + try { + src = fs.readFileSync(absPath, 'utf-8'); + } catch (err) { + throw new Error(`Could not read ${absPath}: ${err.message}`); + } + + // bin/install.js (generated installer) starts with a `#!/usr/bin/env node` shebang + // that espree cannot parse. Rewrite the leading `#!` to `//` so it becomes a valid + // line comment — this preserves byte length and line numbers so any reported + // directive stays at the correct source line. + if (src.startsWith('#!')) src = '//' + src.slice(2); + + // .cts files use TypeScript syntax — use @typescript-eslint/typescript-estree. + // .cjs files use plain JS — use espree (the original parser). + const isCts = absPath.endsWith('.cts'); + + let ast; + try { + if (isCts) { + ast = tsEstree.parse(src, { comment: true, loc: true, range: true }); + } else { + ast = espree.parse(src, { + comment: true, + ecmaVersion: 2022, + loc: true, + range: true, + tolerant: true, + }); + } + } catch (parseErr) { + // C5: fail CLOSED on parse error — a file that fails to parse must FAIL the + // test with its path, not be silently skipped. Silent skip is a false-green: + // an unparseable test file could contain a real disable directive. + throw new Error(`Parse error in ${absPath}: ${parseErr.message}`); + } + + const blanket = []; + const named = []; + + for (const cmt of ast.comments || []) { + const kind = classifyComment(cmt.value); + if (!kind) continue; + const line = cmt.loc ? cmt.loc.start.line : '?'; + const entry = { file: absPath, line, text: cmt.value.trim() }; + if (kind === 'blanket') blanket.push(entry); + else if (kind === 'named') named.push(entry); + } + + return { blanket, named }; +} + +// ── C5: parse-error fail-closed ─────────────────────────────────────────────── + +describe('C5 — scanFile fails closed on parse error', () => { + test('C5a: scanFile throws on parse error instead of silently returning empty result (.cjs path, espree)', () => { + // Inject a parse error deterministically by monkeypatching espree.parse. + // This is the cross-platform approach (works under root/Docker too). + const origParse = espree.parse; + try { + espree.parse = () => { throw new SyntaxError('injected parse error for C5 test'); }; + // Create a minimal real file to scan (use this test file itself, which exists). + assert.throws( + () => scanFile(__filename), + (err) => { + return err instanceof Error && + err.message.includes('injected parse error for C5 test'); + }, + 'scanFile must throw on parse error, not silently return empty result' + ); + } finally { + espree.parse = origParse; + } + }); + + test('W1/C5b: scanFile throws on parse error for .cts path (tsEstree path — fail-closed)', () => { + // W1: The existing C5a test only exercises the espree (.cjs) path. This test + // exercises the tsEstree (.cts) path by monkeypatching tsEstree.parse and + // pointing scanFile at a synthetic .cts-suffixed path. + // + // Cross-platform approach: monkeypatch the module method, not chmod/permissions + // (chmod 0o000 is bypassed by root in Docker and behaves differently per OS). + // + // tsEstree exports 'parse' via a configurable getter (no setter), so we use + // Object.defineProperty to inject a throwing stub, then restore the original + // descriptor in the finally block. + const tsEstreeModule = require('@typescript-eslint/typescript-estree'); + const origDescriptor = Object.getOwnPropertyDescriptor(tsEstreeModule, 'parse'); + const injected = () => { throw new SyntaxError('injected tsEstree parse error for W1/C5b test'); }; + Object.defineProperty(tsEstreeModule, 'parse', { + value: injected, + writable: true, + configurable: true, + enumerable: true, + }); + + // Also monkeypatch fs.readFileSync to return dummy content for the fake .cts + // path, so the .cts branch in scanFile runs without needing a real file. + const origReadFileSync = fs.readFileSync; + fs.readFileSync = (p, enc) => { + if (typeof p === 'string' && p.endsWith('.cts')) return '// dummy cts content'; + return origReadFileSync.call(fs, p, enc); + }; + + try { + assert.throws( + () => scanFile(path.join(__dirname, 'dummy-fixture.cts')), + (err) => { + return err instanceof Error && + err.message.includes('injected tsEstree parse error for W1/C5b test'); + }, + 'scanFile must throw on tsEstree parse error for .cts files (fail-closed)' + ); + } finally { + fs.readFileSync = origReadFileSync; + // Restore original descriptor (getter-only) + Object.defineProperty(tsEstreeModule, 'parse', origDescriptor); + } + }); +}); + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('portability-rule disable-ban (ADR-1703)', () => { + const testFiles = collectTestFiles(); + + test('test file enumeration finds at least 10 test files', () => { + assert.ok( + testFiles.length >= 10, + `Expected at least 10 test files, got ${testFiles.length}`, + ); + }); + + test('no test file contains a named eslint-disable for a portability rule (category a)', () => { + const offenders = []; + for (const absPath of testFiles) { + const { named } = scanFile(absPath); + for (const o of named) { + offenders.push(`${path.relative(path.join(__dirname, '..'), o.file)}:${o.line} — ${o.text}`); + } + } + assert.deepStrictEqual( + offenders, + [], + 'Found inline disable directives suppressing protected portability rules.\n' + + 'These MUST be removed — the rule exists to enforce cross-platform safety:\n\n' + + offenders.map(s => ' ' + s).join('\n'), + ); + }); + + test('no test file contains a blanket eslint-disable (category b — suppresses all rules including portability)', () => { + const offenders = []; + for (const absPath of testFiles) { + const { blanket } = scanFile(absPath); + for (const o of blanket) { + offenders.push(`${path.relative(path.join(__dirname, '..'), o.file)}:${o.line} — ${o.text}`); + } + } + assert.deepStrictEqual( + offenders, + [], + 'Found blanket eslint-disable directives in test files.\n' + + 'Blanket disables suppress ALL rules including portability rules and are banned.\n' + + 'Replace with targeted per-rule disables for non-portability rules, or remove:\n\n' + + offenders.map(s => ' ' + s).join('\n'), + ); + }); +}); diff --git a/tests/portability-vocab-drift.test.cjs b/tests/portability-vocab-drift.test.cjs new file mode 100644 index 000000000..d95872352 --- /dev/null +++ b/tests/portability-vocab-drift.test.cjs @@ -0,0 +1,485 @@ +'use strict'; + +/** + * portability-vocab-drift.test.cjs + * + * Drift-guard: ensure that every exported function from src/runtime-homes.cts + * that returns a filesystem path is listed in PATH_RETURNING_FNS + * (eslint-rules/lib/portability-vocab.cjs). + * + * Method: + * 1. Parse src/runtime-homes.cts with @typescript-eslint/parser. + * 2. Collect `export function ` declarations where the body contains + * a path-building expression (path.join, path.dirname, os.homedir, + * expandTilde, or returns something with "Dir" / "Path" / "Base" in its name). + * 3. Assert each collected name is in PATH_RETURNING_FNS or is listed in + * IGNORED_NON_PATH_EXPORTS (with a reason comment per entry). + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const tsParser = require('@typescript-eslint/parser'); +const espree = require('espree'); + +const { PATH_RETURNING_FNS } = require('../eslint-rules/lib/portability-vocab.cjs'); + +// Functions exported from runtime-homes.cts that do NOT return a filesystem +// path and therefore are intentionally excluded from PATH_RETURNING_FNS. +const IGNORED_NON_PATH_EXPORTS = new Set([ + // resolveConfigHomeFromDescriptor: internal/exported but delegates to path-returning helpers; + // it IS included in PATH_RETURNING_FNS under its bare name (no object prefix needed). + // detectAntigravityDirAmbiguity: returns an object (AntigravityAmbiguity), not a path string. + 'detectAntigravityDirAmbiguity', +]); + +// bin/install.js path-returning helpers that are registered in PATH_RETURNING_FNS. +// This is the curated set named by ADR-1703 L114-119 ("the relevant bin/install.js +// exports"). The companion test below locks it two ways: each must still be DEFINED +// in the generated installer (catches a rename/removal making the vocab entry stale), +// AND this list must equal the PATH_RETURNING_FNS bare-names that are defined in +// bin/install.js (so the curation cannot drift silently out of sync with the vocab). +const INSTALL_JS_PATH_HELPERS = [ + 'getConfigDirFromHome', + 'getGlobalDir', + 'resolveKiloConfigPath', + 'resolveOpencodeConfigPath', + 'computePathPrefix', + 'normalizeInstallRelativePath', +]; + +describe('portability-vocab drift guard', () => { + test('PATH_RETURNING_FNS is a non-empty array', () => { + assert.ok(Array.isArray(PATH_RETURNING_FNS)); + assert.ok(PATH_RETURNING_FNS.length > 0); + }); + + test('PATH_RETURNING_FNS includes the Node builtins', () => { + const builtins = ['path.join', 'path.resolve', 'path.dirname', 'path.basename', 'path.normalize', 'path.relative', 'os.homedir', 'os.tmpdir']; + for (const fn of builtins) { + assert.ok(PATH_RETURNING_FNS.includes(fn), `Expected PATH_RETURNING_FNS to include builtin "${fn}"`); + } + }); + + test('every path-returning export from runtime-homes.cts is in PATH_RETURNING_FNS or IGNORED', () => { + const srcPath = path.join(__dirname, '..', 'src', 'runtime-homes.cts'); + const src = fs.readFileSync(srcPath, 'utf-8'); + + // Parse with @typescript-eslint/parser (handles TypeScript syntax) + const ast = tsParser.parse(src, { + jsx: false, + loc: true, + range: true, + comment: true, + tokens: false, + }); + + // Collect exported function names whose body looks path-returning: + // - body contains a call to path.join / path.dirname / os.homedir / expandTilde + // - OR function name ends with Dir, Path, Base, Home, or starts with resolve/get + const pathReturningExports = []; + + function bodyText(node) { + // Slice the source for the function body + if (node.range) return src.slice(node.range[0], node.range[1]); + return ''; + } + + function looksPathReturning(funcNode, name) { + const body = bodyText(funcNode.body ?? funcNode); + const pathBuilders = [ + 'path.join', 'path.resolve', 'path.dirname', 'path.basename', + 'path.normalize', 'path.relative', 'os.homedir', 'os.tmpdir', + 'expandTilde', 'expandTildeWithHome', 'resolveConfigHome', + ]; + if (pathBuilders.some(p => body.includes(p))) return true; + // Name heuristic: resolveXxx / getXxxDir / getXxxPath / getXxxBase + if (/^(resolve|get)[A-Z]/.test(name) && /Dir|Path|Base|Home|Skills/.test(name)) return true; + return false; + } + + // Build a lookup from top-level declaration names to their function nodes, + // to resolve `export { name }` specifier exports (C4). + const topLevelFunctionNodes = new Map(); // name → funcNode + for (const node of ast.body) { + // function (...) { ... } (non-exported declaration) + if ( + node.type === 'FunctionDeclaration' && + node.id + ) { + topLevelFunctionNodes.set(node.id.name, node); + } + // const = () => ... (non-exported const arrow/function) + if (node.type === 'VariableDeclaration') { + for (const decl of node.declarations) { + if ( + decl.type === 'VariableDeclarator' && + decl.id && + decl.id.type === 'Identifier' && + decl.init && + (decl.init.type === 'ArrowFunctionExpression' || + decl.init.type === 'FunctionExpression') + ) { + topLevelFunctionNodes.set(decl.id.name, decl.init); + } + } + } + // export function / export const — also register in the map + if ( + node.type === 'ExportNamedDeclaration' && + node.declaration && + node.declaration.type === 'FunctionDeclaration' && + node.declaration.id + ) { + topLevelFunctionNodes.set(node.declaration.id.name, node.declaration); + } + if ( + node.type === 'ExportNamedDeclaration' && + node.declaration && + node.declaration.type === 'VariableDeclaration' + ) { + for (const decl of node.declaration.declarations) { + if ( + decl.type === 'VariableDeclarator' && + decl.id && + decl.id.type === 'Identifier' && + decl.init && + (decl.init.type === 'ArrowFunctionExpression' || + decl.init.type === 'FunctionExpression') + ) { + topLevelFunctionNodes.set(decl.id.name, decl.init); + } + } + } + } + + for (const node of ast.body) { + // export function (...) { ... } + if ( + node.type === 'ExportNamedDeclaration' && + node.declaration && + node.declaration.type === 'TSDeclareFunction' === false && + (node.declaration.type === 'FunctionDeclaration') && + node.declaration.id + ) { + const name = node.declaration.id.name; + if (looksPathReturning(node.declaration, name)) { + pathReturningExports.push(name); + } + } + + // export const = ( | ) + if ( + node.type === 'ExportNamedDeclaration' && + node.declaration && + node.declaration.type === 'VariableDeclaration' + ) { + for (const decl of node.declaration.declarations) { + if ( + decl.type === 'VariableDeclarator' && + decl.id && + decl.id.type === 'Identifier' && + decl.init && + (decl.init.type === 'ArrowFunctionExpression' || + decl.init.type === 'FunctionExpression') + ) { + const name = decl.id.name; + if (looksPathReturning(decl.init, name)) { + pathReturningExports.push(name); + } + } + } + } + + // export { name1, name2 } — specifier exports (C4) + // Resolve each specifier to its in-file function/const declaration. + if ( + node.type === 'ExportNamedDeclaration' && + !node.declaration && + node.source == null && // not a re-export from another module + Array.isArray(node.specifiers) + ) { + for (const specifier of node.specifiers) { + if ( + specifier.type === 'ExportSpecifier' && + specifier.local && + specifier.local.type === 'Identifier' + ) { + const name = specifier.local.name; + const exportedName = + specifier.exported && specifier.exported.type === 'Identifier' + ? specifier.exported.name + : name; + const funcNode = topLevelFunctionNodes.get(name); + if (funcNode && looksPathReturning(funcNode, exportedName)) { + pathReturningExports.push(exportedName); + } + } + } + } + } + + // Verify we found at least a few (guards against parser silently failing) + assert.ok( + pathReturningExports.length >= 3, + `Expected at least 3 path-returning exports, got ${pathReturningExports.length}: [${pathReturningExports.join(', ')}]` + ); + + const vocabSet = new Set(PATH_RETURNING_FNS); + const missing = []; + for (const name of pathReturningExports) { + if (!vocabSet.has(name) && !IGNORED_NON_PATH_EXPORTS.has(name)) { + missing.push(name); + } + } + + assert.deepStrictEqual( + missing, + [], + `These path-returning exports from runtime-homes.cts are missing from PATH_RETURNING_FNS:\n ${missing.join('\n ')}\n\nEither add them to PATH_RETURNING_FNS in eslint-rules/lib/portability-vocab.cjs or add them to IGNORED_NON_PATH_EXPORTS with a reason.` + ); + }); + + test('export const arrow-function returning path.join would be required in PATH_RETURNING_FNS', () => { + // Simulate parsing a snippet with `export const myArrowResolver = (x) => path.join(home, x)` + // and verify the drift-guard collector would pick it up (i.e. it's NOT silently bypassed). + const snippetSrc = ` + export const myArrowResolver = (x) => path.join('/home', x); + `; + const ast = tsParser.parse(snippetSrc, { + jsx: false, + loc: true, + range: true, + comment: true, + tokens: false, + }); + + const collected = []; + for (const node of ast.body) { + if ( + node.type === 'ExportNamedDeclaration' && + node.declaration && + node.declaration.type === 'VariableDeclaration' + ) { + for (const decl of node.declaration.declarations) { + if ( + decl.type === 'VariableDeclarator' && + decl.id && + decl.id.type === 'Identifier' && + decl.init && + (decl.init.type === 'ArrowFunctionExpression' || + decl.init.type === 'FunctionExpression') + ) { + const name = decl.id.name; + const bodyTxt = snippetSrc.slice(decl.init.range[0], decl.init.range[1]); + if (['path.join', 'path.resolve', 'path.dirname'].some(p => bodyTxt.includes(p))) { + collected.push(name); + } + } + } + } + } + + assert.deepStrictEqual(collected, ['myArrowResolver'], + 'Arrow-function path export should be collected by the drift guard, requiring it in PATH_RETURNING_FNS'); + + // Confirm PATH_RETURNING_FNS does NOT already contain this fictional name + // (so the test demonstrates a missing entry would be caught, not silently pass). + assert.ok( + !PATH_RETURNING_FNS.includes('myArrowResolver'), + 'myArrowResolver should not be in PATH_RETURNING_FNS (it is a test fixture name)', + ); + }); + + test('C4: export { name } specifier form — path resolver would be required in PATH_RETURNING_FNS', () => { + // Demonstrate that the drift guard now handles `export { mySpecifierResolver }` where + // the function is declared separately (not inline in the export statement). + const snippetSrc = ` + function mySpecifierResolver(x) { + return path.join('/home', x); + } + export { mySpecifierResolver }; + `; + const ast = tsParser.parse(snippetSrc, { + jsx: false, + loc: true, + range: true, + comment: true, + tokens: false, + }); + + // Replicate the drift-guard's specifier-resolution logic (C4 addition). + const topLevelFns = new Map(); + for (const node of ast.body) { + if (node.type === 'FunctionDeclaration' && node.id) { + topLevelFns.set(node.id.name, node); + } + } + + const collected = []; + for (const node of ast.body) { + if ( + node.type === 'ExportNamedDeclaration' && + !node.declaration && + node.source == null && + Array.isArray(node.specifiers) + ) { + for (const specifier of node.specifiers) { + if ( + specifier.type === 'ExportSpecifier' && + specifier.local && + specifier.local.type === 'Identifier' + ) { + const localName = specifier.local.name; + const funcNode = topLevelFns.get(localName); + if (funcNode) { + const bodyTxt = snippetSrc.slice(funcNode.range[0], funcNode.range[1]); + if (['path.join', 'path.resolve', 'path.dirname'].some(p => bodyTxt.includes(p))) { + collected.push(localName); + } + } + } + } + } + } + + assert.deepStrictEqual(collected, ['mySpecifierResolver'], + 'export { name } specifier form should be detected by drift guard, requiring entry in PATH_RETURNING_FNS'); + + assert.ok( + !PATH_RETURNING_FNS.includes('mySpecifierResolver'), + 'mySpecifierResolver should not be in PATH_RETURNING_FNS (it is a test fixture name)', + ); + }); + + // ─── ADR-1703 L114-119: the drift guard also covers "the relevant bin/install.js + // exports". The generated installer is a path-heavy 12k-line CommonJS file where + // the loose body-contains heuristic (used for runtime-homes.cts) is UNSOUND — it + // produces ~33 false positives because nearly every function uses path.join. The + // two checks below use SOUND shapes only, and document the residual boundary. + test('bin/install.js: every function that DIRECTLY returns a path.* call is in PATH_RETURNING_FNS', () => { + const srcPath = path.join(__dirname, '..', 'bin', 'install.js'); + const raw = fs.readFileSync(srcPath, 'utf8'); + // bin/install.js starts with a shebang espree cannot parse — rewrite #! -> //. + const src = raw.startsWith('#!') ? '//' + raw.slice(2) : raw; + const ast = espree.parse(src, { ecmaVersion: 2022, loc: true, range: true, tolerant: true }); + + // SOUND shape: a top-level function whose body contains a ReturnStatement + // whose argument is a CallExpression to path.join/resolve/dirname/normalize/ + // relative. This is tight (0 false positives on the current installer) and + // catches the canonical resolver shape (resolveOpencodeConfigPath, + // resolveKiloConfigPath). It does NOT catch a resolver that builds a path + // into a temp variable then returns the temp — see the boundary note below. + function returnsPathCall(funcBody) { + let found = false; + function walk(n) { + if (found || !n || typeof n !== 'object') return; + if (n.type === 'ReturnStatement' && n.argument && n.argument.type === 'CallExpression') { + const callee = n.argument.callee; + if ( + callee.type === 'MemberExpression' && + !callee.computed && + callee.object.type === 'Identifier' && + callee.object.name === 'path' && + callee.property.type === 'Identifier' && + ['join', 'resolve', 'dirname', 'normalize', 'relative', 'basename'].includes(callee.property.name) + ) { + found = true; + return; + } + } + // Do NOT descend into nested function expressions/declarations — a path + // return inside a nested callback does not make the outer fn path-returning. + if (n.type === 'FunctionExpression' || n.type === 'ArrowFunctionExpression' || n.type === 'FunctionDeclaration') return; + for (const key of Object.keys(n)) { + if (key === 'parent' || key === 'tokens' || key === 'comments') continue; + const child = n[key]; + if (Array.isArray(child)) { + for (const item of child) { + if (item && typeof item === 'object' && item.type) walk(item); + } + } else if (child && typeof child === 'object' && child.type) { + walk(child); + } + } + } + walk(funcBody); + return found; + } + + const pathReturning = []; + for (const node of ast.body) { + if (node.type === 'FunctionDeclaration' && node.id && node.body) { + if (returnsPathCall(node.body)) pathReturning.push(node.id.name); + } + } + + const vocabSet = new Set(PATH_RETURNING_FNS); + const missing = pathReturning.filter((n) => !vocabSet.has(n)); + assert.deepStrictEqual( + missing, + [], + `These bin/install.js functions return a path.* call but are missing from PATH_RETURNING_FNS:\n ${missing.join('\n ')}\n\nRegister them in eslint-rules/lib/portability-vocab.cjs (or, if they do not return a string path that flows into content/assertions, document why).`, + ); + }); + + test('bin/install.js: the curated INSTALL_JS_PATH_HELPERS still exist (no stale vocab entries after a rename)', () => { + const srcPath = path.join(__dirname, '..', 'bin', 'install.js'); + const raw = fs.readFileSync(srcPath, 'utf8'); + const src = raw.startsWith('#!') ? '//' + raw.slice(2) : raw; + const ast = espree.parse(src, { ecmaVersion: 2022, loc: true, range: true, tolerant: true }); + + // Collect every top-level function-declaration AND const/let/var name defined + // in the installer (path helpers may be `function foo(){}` OR a const import + // like `const computePathPrefix = runtimeArtifactConversion._computePathPrefix`). + const defined = new Set(); + for (const node of ast.body) { + if (node.type === 'FunctionDeclaration' && node.id) defined.add(node.id.name); + if (node.type === 'VariableDeclaration') { + for (const decl of node.declarations) { + if (decl.type === 'VariableDeclarator' && decl.id && decl.id.type === 'Identifier') { + defined.add(decl.id.name); + } + } + } + } + + // (a) Each curated helper must still be defined — catches a rename/removal + // that would leave a stale entry in PATH_RETURNING_FNS (the rule would + // silently stop matching the renamed resolver). + const missing = INSTALL_JS_PATH_HELPERS.filter((n) => !defined.has(n)); + assert.deepStrictEqual( + missing, + [], + `These curated bin/install.js path helpers are no longer defined in bin/install.js — their PATH_RETURNING_FNS entries are now stale:\n ${missing.join('\n ')}\n\nRename them in eslint-rules/lib/portability-vocab.cjs PATH_RETURNING_FNS and in INSTALL_JS_PATH_HELPERS here.`, + ); + + // (b) The curated list must equal the PATH_RETURNING_FNS bare-names that are + // defined in bin/install.js — so the curation cannot drift out of sync + // with the vocab. If a new install.js helper is added to PATH_RETURNING_FNS, + // it must also be added to INSTALL_JS_PATH_HELPERS (and vice versa). + const vocabSet = new Set(PATH_RETURNING_FNS); + const vocabHelpersDefinedInInstallJs = [...vocabSet].filter((n) => defined.has(n) && !n.includes('.')).sort(); + assert.deepStrictEqual( + [...INSTALL_JS_PATH_HELPERS].sort(), + vocabHelpersDefinedInInstallJs, + `INSTALL_JS_PATH_HELPERS is out of sync with PATH_RETURNING_FNS entries defined in bin/install.js.\n` + + ` curated list: [${[...INSTALL_JS_PATH_HELPERS].sort().join(', ')}]\n` + + ` vocab ∩ install.js: [${vocabHelpersDefinedInInstallJs.join(', ')}]\n` + + `Reconcile the two lists.`, + ); + }); +}); + +// ─── Known boundary (documented, not enforced) ───────────────────────────────── +// +// A NEW path-returning resolver added to bin/install.js that builds its path via +// a temp variable (`const p = path.join(...); return p;`) or by delegating to +// another helper (`return getGlobalDir(...)`) is NOT caught by the tight +// return-path.* check above (which requires `return path.join(...)` directly). +// The looser body-contains heuristic is unsound here (33 FPs on the current +// installer). The installer's path API is a small, stable, curated set; a new +// resolver there is caught at code review (the active resolver module, +// src/runtime-homes.cts, IS fully drift-guarded by the looser heuristic above, +// which is sound for that focused module). diff --git a/tests/product-name-purity.test.cjs b/tests/product-name-purity.test.cjs index 44b4ce53d..d834d1541 100644 --- a/tests/product-name-purity.test.cjs +++ b/tests/product-name-purity.test.cjs @@ -84,7 +84,7 @@ describe('product name purity (#1777)', () => { for (const file of README_FILES) { const content = fs.readFileSync(path.join(ROOT, file), 'utf-8'); - const lines = content.split('\n'); + const lines = content.split(/\r?\n/); for (let i = 0; i < lines.length; i++) { const line = lines[i]; diff --git a/tests/prohibition-probe.docs-fixtures.test.cjs b/tests/prohibition-probe.docs-fixtures.test.cjs index 43ffdbc76..3481a1c4a 100644 --- a/tests/prohibition-probe.docs-fixtures.test.cjs +++ b/tests/prohibition-probe.docs-fixtures.test.cjs @@ -24,7 +24,7 @@ const fixturesRoot = path.join(__dirname, '..', 'gsd-core', 'references', 'prohi // Extract fenced blocks tagged ```json prohibition-probe:/ from the doc, keyed by ref. // The \n? before the closing fence allows blocks whose closing fence has no preceding newline. function taggedJsonBlocks(md) { - const re = /```json prohibition-probe:([^\n]+)\n([\s\S]*?)\n?```/g; + const re = /```json prohibition-probe:([^\r\n]+)\r?\n([\s\S]*?)\r?\n?```/g; const out = {}; let m; while ((m = re.exec(md))) out[m[1].trim()] = m[2]; diff --git a/tests/prompt-injection-scan.security.test.cjs b/tests/prompt-injection-scan.security.test.cjs index c32d98c97..751098e90 100644 --- a/tests/prompt-injection-scan.security.test.cjs +++ b/tests/prompt-injection-scan.security.test.cjs @@ -286,7 +286,7 @@ describe('codebase prompt injection scan', () => { const content = fs.readFileSync(file, 'utf-8'); if (invisiblePattern.test(content)) { // Find the line numbers with invisible chars - const lines = content.split('\n'); + const lines = content.split(/\r?\n/); const badLines = []; lines.forEach((line, i) => { if (invisiblePattern.test(line)) { diff --git a/tests/qwen-skills-migration.test.cjs b/tests/qwen-skills-migration.test.cjs index c2ee81d80..18b6ddf3f 100644 --- a/tests/qwen-skills-migration.test.cjs +++ b/tests/qwen-skills-migration.test.cjs @@ -21,9 +21,10 @@ const fs = require('fs'); const { convertClaudeCommandToClaudeSkill, - installRuntimeArtifacts, } = require('../bin/install.js'); +const { installRuntimeArtifacts } = require('../gsd-core/bin/lib/install-engine.cjs'); + const { loadSkillsManifest, resolveProfile, @@ -97,7 +98,7 @@ describe('Qwen Code: convertClaudeCommandToClaudeSkill', () => { }); test('preserves body content unchanged', () => { - const body = '\n\nDo the thing.\n\n\n\nStep 1.\nStep 2.\n\n'; + const body = '\n\nDo the thing.\n\r?\n\n\nStep 1.\nStep 2.\n\n'; const input = [ '---', 'name: gsd:test', @@ -299,10 +300,10 @@ describe('Qwen Code: SKILL.md format validation', () => { const result = convertClaudeCommandToClaudeSkill(input, 'gsd-review'); // Parse the frontmatter - const fmMatch = result.match(/^---\n([\s\S]*?)\n---/); + const fmMatch = result.match(/^---\r?\n([\s\S]*?)\r?\n---/); assert.ok(fmMatch, 'has frontmatter block'); - const fmLines = fmMatch[1].split('\n'); + const fmLines = fmMatch[1].split(/\r?\n/); const hasName = fmLines.some(l => l.startsWith('name: gsd-review')); const hasDesc = fmLines.some(l => l.startsWith('description:')); const hasAgent = fmLines.some(l => l.startsWith('agent:')); diff --git a/tests/reapply-patches.test.cjs b/tests/reapply-patches.test.cjs index db7832e3f..2b85c4bae 100644 --- a/tests/reapply-patches.test.cjs +++ b/tests/reapply-patches.test.cjs @@ -269,7 +269,7 @@ function parseFrontmatterField(content, field) { * against the Hunk Verification Table without raw substring matching. */ function parsePipeTable(content, expectedHeaderTokens) { - const lines = content.split('\n'); + const lines = content.split(/\r?\n/); for (let i = 0; i < lines.length - 1; i++) { const headerLine = lines[i].trim(); const sepLine = (lines[i + 1] || '').trim(); @@ -332,7 +332,7 @@ describe('reapply-patches workflow contract (#1469)', () => { ].map((m) => m[1]); assert.ok(blocks.length > 0, 'update.md must define at least one block'); const includes = blocks - .flatMap((blk) => blk.split('\n')) + .flatMap((blk) => blk.split(/\r?\n/)) .map((l) => l.trim()) .filter((l) => l.startsWith('@')) .map((l) => l.replace(/^@/, '')); @@ -381,7 +381,7 @@ describe('reapply-patches gated hunk verification (#1999)', () => { // assert it both names the table and defines an explicit gate // condition tied to the `verified` column. const content = fs.readFileSync(workflowPath, 'utf8'); - const step5Match = content.match(/^##\s+Step 5[^\n]*\n([\s\S]*?)(?=^##\s|Z)/m); + const step5Match = content.match(/^##\s+Step 5[^\r\n]*\r?\n([\s\S]*?)(?=^##\s|Z)/m); assert.ok(step5Match, 'reapply-patches workflow must contain a "## Step 5" section'); const step5 = step5Match[1]; assert.ok( @@ -405,7 +405,7 @@ describe('reapply-patches gated hunk verification (#1999)', () => { test('Step 5 also halts when the Hunk Verification Table is absent (Step 4 produced nothing)', () => { // Independent gate: missing-table is a separate halt path from any-no-row. const content = fs.readFileSync(workflowPath, 'utf8'); - const step5Match = content.match(/^##\s+Step 5[^\n]*\n([\s\S]*?)(?=^##\s|Z)/m); + const step5Match = content.match(/^##\s+Step 5[^\r\n]*\r?\n([\s\S]*?)(?=^##\s|Z)/m); assert.ok(step5Match, 'Step 5 section must exist'); const step5 = step5Match[1]; const handlesAbsent = /(table is absent|table is missing|missing.*table|absent.*table)/i.test(step5); diff --git a/tests/release-coverage-scope.test.cjs b/tests/release-coverage-scope.test.cjs index f795a855a..438c8887c 100644 --- a/tests/release-coverage-scope.test.cjs +++ b/tests/release-coverage-scope.test.cjs @@ -13,7 +13,7 @@ const RELEASE_WORKFLOW = path.join(__dirname, '..', '.github', 'workflows', 'rel describe('release-coverage-scope', () => { test('release.yml uses test:coverage:unit (not full suite) in both rc and finalize gates', () => { - const lines = fs.readFileSync(RELEASE_WORKFLOW, 'utf8').split('\n').map(l => l.trim()); + const lines = fs.readFileSync(RELEASE_WORKFLOW, 'utf8').split(/\r?\n/).map(l => l.trim()); const bareCount = lines.filter(l => l === 'npm run test:coverage').length; const unitCount = lines.filter(l => l === 'npm run test:coverage:unit').length; assert.strictEqual(bareCount, 0, diff --git a/tests/require-fs-op-fallback.rule.test.cjs b/tests/require-fs-op-fallback.rule.test.cjs new file mode 100644 index 000000000..35d2952ed --- /dev/null +++ b/tests/require-fs-op-fallback.rule.test.cjs @@ -0,0 +1,368 @@ +'use strict'; + +/** + * require-fs-op-fallback.rule.test.cjs + * + * RuleTester unit tests for the local/require-fs-op-fallback ESLint rule. + * + * Rule: flag a bare fs.rename / fs.renameSync call (the atomic-publish + * primitive named first in DEFECT.WINDOWS-FS-OPS.symptom) that is NOT either: + * (a) inside a try/catch (the NEAREST catching try) whose catch handler BOTH + * references a transient errno ('EPERM' / 'EBUSY' / 'EACCES', literally + * or via a *RETRY_ERRNOS set) AND carries a retry signal (a loop + * `continue` backedge or a `return ` delegation — NOT a bare + * rethrow: the cure is retry, not just errno recognition), OR + * (b) control-dependent on a Windows platform guard + * (process.platform !== 'win32' / early-return — isWindowsExcludedNode). + * + * copyFile / unlink are deliberately NOT flagged: per the defect's own + * .fix-forward ("catch EPERM/EBUSY/EACCES, fall back to copy + unlink with + * retry") they are the FALLBACK PRIMITIVES, not separate defect sites, and + * unlink has ~30 intentional best-effort try/catch-swallow cleanup sites that + * would be a FP minefield. See the issue #1740 scope note. + * + * DEFECT category: DEFECT.WINDOWS-FS-OPS + * + * INVALID (violation expected): + * - bare fs.renameSync(tmp, target) — no try/catch, no guard + * - fs.renameSync inside try/catch (e) {} — silent swallow, no errno ref + * - fs.renameSync inside try/catch that cleans up + rethrows, no errno ref + * (the atomicWriteFileSync / atomicWriteInstallState shape — the real bug) + * - bare fs.rename(...) async + * - fs.renameSync inside try/catch whose catch checks errno but only RETHROWS + * (HIGH-1 from codex review: errno reference alone is insufficient — no retry) + * - fs.renameSync inside an INNER try whose catch swallows, even with an OUTER + * try whose catch handles EPERM (HIGH-2: outer catch is unreachable) + * + * VALID (no violation): + * - fs.renameSync inside a retry loop whose catch checks errno + `continue` + * - fs.renameSync inside try/catch whose catch references RENAME_RETRY_ERRNOS set + * - fs.renameSync inside try/catch with switch(err.code) + return retry() (delegation) + * - fs.renameSync inside if (process.platform !== 'win32') { ... } + * - fs.renameSync after early-return guard / hoisted isWindows boolean + * - fs.renameSync inside a try-finally, protected by the NEXT enclosing catching try + * - fs.copyFileSync / fs.unlinkSync — NOT flagged (out of scope — fallback primitives) + * - fs.readFileSync / fs.writeFileSync — NOT flagged (not rename) + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const { RuleTester } = require('eslint'); + +const requireFsOpFallback = require('../eslint-rules/require-fs-op-fallback.cjs'); + +const ruleTester = new RuleTester({ + languageOptions: { + ecmaVersion: 2022, + sourceType: 'commonjs', + }, +}); + +// ─── module shape ───────────────────────────────────────────────────────────── + +describe('require-fs-op-fallback rule module', () => { + test('exports meta and create', () => { + assert.strictEqual(typeof requireFsOpFallback.meta, 'object'); + assert.strictEqual(typeof requireFsOpFallback.create, 'function'); + assert.strictEqual(requireFsOpFallback.meta.type, 'problem'); + assert.ok(requireFsOpFallback.meta.messages.requireFsOpFallback); + }); +}); + +// ─── INVALID cases (violation expected) ─────────────────────────────────────── + +describe('require-fs-op-fallback invalid cases', () => { + test('invalid: bare fs.renameSync with no try/catch and no guard', () => { + // The canonical atomic-publish defect: a reader holding the target open + // makes renameSync throw EPERM/EBUSY on Windows, which propagates unhandled. + ruleTester.run('require-fs-op-fallback', requireFsOpFallback, { + valid: [], + invalid: [ + { + code: `function publish(tmp, target) { + fs.renameSync(tmp, target); +}`, + errors: [{ messageId: 'requireFsOpFallback' }], + }, + ], + }); + }); + + test('invalid: fs.renameSync inside try/catch (e) {} — silent swallow, no errno ref', () => { + // "never silently swallow" — the defect fix-forward explicitly forbids this. + ruleTester.run('require-fs-op-fallback', requireFsOpFallback, { + valid: [], + invalid: [ + { + code: `function publish(tmp, target) { + try { fs.renameSync(tmp, target); } catch (e) {} +}`, + errors: [{ messageId: 'requireFsOpFallback' }], + }, + ], + }); + }); + + test('invalid: fs.renameSync inside try/catch that cleans up + rethrows, no errno ref (atomicWriteFileSync shape)', () => { + // This is the real production bug: the catch handles a write-failure cleanup + // path but does NOT retry the transient Windows lock — EPERM/EBUSY throws + // immediately without the established RENAME_RETRY_ERRNOS backoff. + ruleTester.run('require-fs-op-fallback', requireFsOpFallback, { + valid: [], + invalid: [ + { + code: `function atomicWriteFileSync(target, data) { + const tmp = target + '.tmp'; + try { + fs.writeFileSync(tmp, data); + fs.renameSync(tmp, target); + } catch (e) { + try { fs.rmSync(tmp, { force: true }); } catch { /* ignore */ } + throw e; + } +}`, + errors: [{ messageId: 'requireFsOpFallback' }], + }, + ], + }); + }); + + test('invalid: bare fs.rename(...) async', () => { + ruleTester.run('require-fs-op-fallback', requireFsOpFallback, { + valid: [], + invalid: [ + { + code: `function publishAsync(tmp, target, cb) { + fs.rename(tmp, target, cb); +}`, + errors: [{ messageId: 'requireFsOpFallback' }], + }, + ], + }); + }); + + test('invalid: fs.renameSync inside try/catch whose catch checks errno but only RETHROWS (no retry)', () => { + // HIGH-1 (codex review): referencing the errno is not enough — the defect's + // cure is retry/fallback, not just recognition. A catch that checks the + // errno and rethrows (no continue / no delegation) still fails on Windows + // transient locks, so it is a violation. + ruleTester.run('require-fs-op-fallback', requireFsOpFallback, { + valid: [], + invalid: [ + { + code: `function publish(tmp, target) { + try { + fs.renameSync(tmp, target); + } catch (e) { + if (e.code === 'EPERM') throw e; + throw e; + } +}`, + errors: [{ messageId: 'requireFsOpFallback' }], + }, + ], + }); + }); + + test('invalid: fs.renameSync inside try/catch whose catch references an UNRELATED errno (ENOENT) only', () => { + // A catch handling ENOENT does NOT protect against the EPERM/EBUSY/EACCES + // transient-lock family — still a violation. + ruleTester.run('require-fs-op-fallback', requireFsOpFallback, { + valid: [], + invalid: [ + { + code: `function publish(tmp, target) { + try { + fs.renameSync(tmp, target); + } catch (e) { + if (e.code === 'ENOENT') return; + throw e; + } +}`, + errors: [{ messageId: 'requireFsOpFallback' }], + }, + ], + }); + }); +}); + +// ─── VALID cases (no violation) ─────────────────────────────────────────────── + +describe('require-fs-op-fallback valid cases', () => { + test('valid: fs.renameSync inside a retry loop whose catch checks err.code === "EPERM" and continues', () => { + // The minimal compliant shape: errno check + loop backedge (continue). + ruleTester.run('require-fs-op-fallback', requireFsOpFallback, { + valid: [ + `function publish(tmp, target) { + for (let attempt = 1; attempt <= 3; attempt++) { + try { + fs.renameSync(tmp, target); + return; + } catch (e) { + if (e.code === 'EPERM') { backoff(); continue; } + throw e; + } + } +}`, + ], + invalid: [], + }); + }); + + test('valid: fs.renameSync inside try/catch whose catch references RENAME_RETRY_ERRNOS set (canonical pattern)', () => { + ruleTester.run('require-fs-op-fallback', requireFsOpFallback, { + valid: [ + `const RENAME_RETRY_ERRNOS = new Set(['EPERM', 'EBUSY', 'EACCES']); +function atomicRenameWithRetry(tmpPath, filePath) { + for (let attempt = 1; attempt <= 3; attempt++) { + try { + fs.renameSync(tmpPath, filePath); + return null; + } catch (err) { + if (attempt < 3 && RENAME_RETRY_ERRNOS.has(err.code)) { + backoff(); + continue; + } + break; + } + } +}`, + ], + invalid: [], + }); + }); + + test('valid: fs.renameSync inside try/catch with switch(err.code) casing EBUSY and EACCES, delegating via return retry()', () => { + // The `return retry()` is a ReturnStatement-with-CallExpression — a retry + // signal (delegation to a helper that performs its own bounded retry). + ruleTester.run('require-fs-op-fallback', requireFsOpFallback, { + valid: [ + `function publish(tmp, target) { + try { + fs.renameSync(tmp, target); + } catch (e) { + switch (e.code) { + case 'EBUSY': + case 'EACCES': + return retry(); + } + throw e; + } +}`, + ], + invalid: [], + }); + }); + + test('valid: fs.renameSync inside if (process.platform !== "win32") block (platform guard)', () => { + ruleTester.run('require-fs-op-fallback', requireFsOpFallback, { + valid: [ + `function publish(tmp, target) { + if (process.platform !== 'win32') { + fs.renameSync(tmp, target); + } +}`, + ], + invalid: [], + }); + }); + + test('valid: fs.renameSync after early-return Windows guard', () => { + ruleTester.run('require-fs-op-fallback', requireFsOpFallback, { + valid: [ + `function publish(tmp, target) { + if (process.platform === 'win32') return; + fs.renameSync(tmp, target); +}`, + ], + invalid: [], + }); + }); + + test('valid: fs.copyFileSync and fs.unlinkSync are NOT flagged (out of scope — fallback primitives)', () => { + ruleTester.run('require-fs-op-fallback', requireFsOpFallback, { + valid: [ + `function stage(src, dest) { fs.copyFileSync(src, dest); }`, + `function cleanup(p) { fs.unlinkSync(p); }`, + `function cleanupSwallow(p) { try { fs.unlinkSync(p); } catch (_) {} }`, + ], + invalid: [], + }); + }); + + test('valid: fs.readFileSync / fs.writeFileSync are NOT flagged (not rename)', () => { + ruleTester.run('require-fs-op-fallback', requireFsOpFallback, { + valid: [ + `function read(p) { return fs.readFileSync(p, 'utf8'); }`, + `function write(p, d) { fs.writeFileSync(p, d); }`, + ], + invalid: [], + }); + }); + + test('invalid: fs.renameSync inside an INNER try whose catch swallows, with an OUTER try whose catch handles EPERM (HIGH-2)', () => { + // HIGH-2 (codex review): the inner catch intercepts the rename error + // (swallows it), so the outer errno-handling catch is UNREACHABLE for that + // failure. Walking the full ancestor chain and treating the outer catch as + // protective was a false negative. The nearest catching try's handler is + // authoritative; since it swallows without retry, this is a violation. + ruleTester.run('require-fs-op-fallback', requireFsOpFallback, { + valid: [], + invalid: [ + { + code: `function publish(tmp, target) { + try { + try { + fs.renameSync(tmp, target); + } catch (inner) { + // inner cleanup, swallows the rename error — no retry + } + } catch (e) { + if (e.code === 'EPERM') { return retry(); } + } +}`, + errors: [{ messageId: 'requireFsOpFallback' }], + }, + ], + }); + }); + + test('valid: try-finally (no catch) is skipped — rename protected by the NEXT enclosing catching try', () => { + // A try with only a finally does not intercept the rename error, so the + // climb continues to the next enclosing TryStatement with a handler. + ruleTester.run('require-fs-op-fallback', requireFsOpFallback, { + valid: [ + `function publish(tmp, target) { + for (let attempt = 1; attempt <= 3; attempt++) { + try { + try { + fs.renameSync(tmp, target); + } finally { + meter.tick(); + } + return; + } catch (e) { + if (e.code === 'EPERM') { continue; } + throw e; + } + } +}`, + ], + invalid: [], + }); + }); + + test('valid: hoisted isWindows boolean guard consumed by if (!isWindows)', () => { + ruleTester.run('require-fs-op-fallback', requireFsOpFallback, { + valid: [ + `function publish(tmp, target) { + const isWindows = process.platform === 'win32'; + if (!isWindows) { + fs.renameSync(tmp, target); + } +}`, + ], + invalid: [], + }); + }); +}); diff --git a/tests/require-userprofile-with-home.rule.test.cjs b/tests/require-userprofile-with-home.rule.test.cjs new file mode 100644 index 000000000..231c76d40 --- /dev/null +++ b/tests/require-userprofile-with-home.rule.test.cjs @@ -0,0 +1,197 @@ +'use strict'; + +/** + * require-userprofile-with-home.rule.test.cjs + * + * RuleTester unit tests for the local/require-userprofile-with-home ESLint rule. + * + * Rule (G6): at Program:exit, if the file assigns process.env.HOME and + * never references USERPROFILE, report each HOME assignment. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const { RuleTester } = require('eslint'); + +const rule = require('../eslint-rules/require-userprofile-with-home.cjs'); + +const ruleTester = new RuleTester({ + languageOptions: { + ecmaVersion: 2022, + sourceType: 'commonjs', + }, +}); + +// ─── module shape ───────────────────────────────────────────────────────────── + +describe('require-userprofile-with-home rule module', () => { + test('exports meta and create', () => { + assert.strictEqual(typeof rule.meta, 'object'); + assert.strictEqual(typeof rule.create, 'function'); + assert.strictEqual(rule.meta.type, 'problem'); + assert.ok(rule.meta.messages.missingUserProfile, 'missingUserProfile message must exist'); + }); +}); + +// ─── INVALID cases ──────────────────────────────────────────────────────────── + +describe('require-userprofile-with-home: invalid cases', () => { + test('invalid: process.env.HOME = "/home/user" with no USERPROFILE reference', () => { + ruleTester.run('require-userprofile-with-home', rule, { + valid: [], + invalid: [ + { + code: `process.env.HOME = '/home/user';`, + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'missingUserProfile' }], + }, + ], + }); + }); + + test('invalid: process.env["HOME"] = dir with no USERPROFILE reference', () => { + ruleTester.run('require-userprofile-with-home', rule, { + valid: [], + invalid: [ + { + code: `process.env['HOME'] = tmpDir;`, + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'missingUserProfile' }], + }, + ], + }); + }); + + test('invalid: beforeEach sets HOME with no USERPROFILE anywhere', () => { + ruleTester.run('require-userprofile-with-home', rule, { + valid: [], + invalid: [ + { + code: ` + beforeEach(() => { + process.env.HOME = '/tmp/test-home'; + }); + `, + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'missingUserProfile' }], + }, + ], + }); + }); + + test('invalid: multiple HOME assignments — all reported when USERPROFILE absent', () => { + ruleTester.run('require-userprofile-with-home', rule, { + valid: [], + invalid: [ + { + code: ` + process.env.HOME = orig; + process.env.HOME = tmpDir; + `, + filename: 'tests/foo.test.cjs', + errors: [ + { messageId: 'missingUserProfile' }, + { messageId: 'missingUserProfile' }, + ], + }, + ], + }); + }); +}); + +// ─── VALID cases ────────────────────────────────────────────────────────────── + +describe('require-userprofile-with-home: valid cases', () => { + test('valid: process.env.HOME assigned AND process.env.USERPROFILE assigned', () => { + ruleTester.run('require-userprofile-with-home', rule, { + valid: [ + { + code: ` + process.env.HOME = tmpDir; + process.env.USERPROFILE = tmpDir; + `, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('invalid: process.env.HOME assigned AND USERPROFILE only read (not assigned) — read is insufficient', () => { + ruleTester.run('require-userprofile-with-home', rule, { + valid: [], + invalid: [ + { + // Reading process.env.USERPROFILE is not enough — the rule requires + // an actual assignment so Windows test environments are set up correctly. + code: ` + process.env.HOME = tmpDir; + const up = process.env.USERPROFILE; + `, + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'missingUserProfile' }], + }, + ], + }); + }); + + test('valid: process.env.HOME assigned AND process.env["USERPROFILE"] assigned', () => { + ruleTester.run('require-userprofile-with-home', rule, { + valid: [ + { + code: ` + process.env.HOME = tmpDir; + process.env['USERPROFILE'] = tmpDir; + `, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('valid: no HOME assignment at all', () => { + ruleTester.run('require-userprofile-with-home', rule, { + valid: [ + { + code: `const home = process.env.HOME;`, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('invalid: USERPROFILE only in a comment does NOT satisfy the rule (comment is not an assignment)', () => { + ruleTester.run('require-userprofile-with-home', rule, { + valid: [], + invalid: [ + { + // A comment mentioning USERPROFILE is insufficient — the rule requires + // an actual process.env.USERPROFILE = … assignment. + code: ` + // also set USERPROFILE on Windows + process.env.HOME = tmpDir; + `, + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'missingUserProfile' }], + }, + ], + }); + }); + + test('valid: process.env.HOME assigned AND process.env.USERPROFILE actually assigned', () => { + ruleTester.run('require-userprofile-with-home', rule, { + valid: [ + { + code: ` + process.env.HOME = tmpDir; + process.env.USERPROFILE = tmpDir; + `, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); +}); diff --git a/tests/roadmap.test.cjs b/tests/roadmap.test.cjs index 7061e9d4a..29102f5d8 100644 --- a/tests/roadmap.test.cjs +++ b/tests/roadmap.test.cjs @@ -915,7 +915,7 @@ describe('roadmap update-plan-progress command', () => { assert.ok(result.success, `Command failed: ${result.error}`); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); - const rowMatch = roadmap.match(/^\|[^\n]*50\. Build[^\n]*$/m); + const rowMatch = roadmap.match(/^\|[^\r\n]*50\. Build[^\r\n]*$/m); assert.ok(rowMatch, 'table row should exist'); const cells = rowMatch[0].split('|').slice(1, -1).map(c => c.trim()); assert.strictEqual(cells.length, 5, 'should have 5 columns'); @@ -1252,7 +1252,7 @@ describe('regressions: insert missing plan rows (#1163)', () => { // ── Adversarial: CRLF in ROADMAP.md ────────────────────────────────────── test('CRLF line endings in ROADMAP.md are handled without corruption', () => { - const content = buildRoadmapBoldPlans('5').replace(/\n/g, '\r\n'); + const content = buildRoadmapBoldPlans('5').replace(/\r?\n/g, '\r\n'); fs.writeFileSync(roadmapPath, content); createPhaseWithPlans(tmpDir, '5', ['5-01-PLAN.md', '5-02-PLAN.md']); diff --git a/tests/runtime-artifact-layout-descriptor-drive.test.cjs b/tests/runtime-artifact-layout-descriptor-drive.test.cjs index a5f086dd3..14bf94450 100644 --- a/tests/runtime-artifact-layout-descriptor-drive.test.cjs +++ b/tests/runtime-artifact-layout-descriptor-drive.test.cjs @@ -44,6 +44,8 @@ const FAKE_DIR = '/tmp/fake-config-dir-dd'; // ── STEP-0 golden (captured from switch BEFORE edits) ──────────────────────── // Format: { kind, destSubpath, prefix } for each entry in kinds[]. // 'function' means we assert typeof kind.stage === 'function'. +// ADR-1235 step 1 (#1763): cursor, windsurf, augment, trae, codebuddy each gained +// an `agents` kind (appended last). Goldens consciously updated post-cutover. const GOLDEN = { // ── claude ────────────────────────────────────────────────────────────────── @@ -58,13 +60,16 @@ const GOLDEN = { // ── cursor ─────────────────────────────────────────────────────────────────── // Old switch: BOTH scopes returned [skills, commands] (no scope branch). // 5b backfill: local == global. + // ADR-1235 step 1 (#1763): agents kind added. 'cursor/global': [ { kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' }, { kind: 'commands', destSubpath: 'commands', prefix: 'gsd-' }, + { kind: 'agents', destSubpath: 'agents', prefix: 'gsd-' }, ], 'cursor/local': [ { kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' }, { kind: 'commands', destSubpath: 'commands', prefix: 'gsd-' }, + { kind: 'agents', destSubpath: 'agents', prefix: 'gsd-' }, ], // ── gemini ─────────────────────────────────────────────────────────────────── @@ -104,29 +109,39 @@ const GOLDEN = { ], // ── windsurf ───────────────────────────────────────────────────────────────── - 'windsurf/global': [], + // ADR-1235 step 1 (#1763): agents kind added to both scopes. + 'windsurf/global': [ + { kind: 'agents', destSubpath: 'agents', prefix: 'gsd-' }, + ], 'windsurf/local': [ { kind: 'commands', destSubpath: 'workflows', prefix: 'gsd-' }, + { kind: 'agents', destSubpath: 'agents', prefix: 'gsd-' }, ], // ── augment ────────────────────────────────────────────────────────────────── // Old switch: no scope branch → local == global. 5b backfill restores this. + // ADR-1235 step 1 (#1763): agents kind added. 'augment/global': [ { kind: 'commands', destSubpath: 'commands', prefix: 'gsd-' }, { kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' }, + { kind: 'agents', destSubpath: 'agents', prefix: 'gsd-' }, ], 'augment/local': [ { kind: 'commands', destSubpath: 'commands', prefix: 'gsd-' }, { kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' }, + { kind: 'agents', destSubpath: 'agents', prefix: 'gsd-' }, ], // ── trae ───────────────────────────────────────────────────────────────────── // Old switch: no scope branch → local == global. 5b backfill restores this. + // ADR-1235 step 1 (#1763): agents kind added. 'trae/global': [ { kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' }, + { kind: 'agents', destSubpath: 'agents', prefix: 'gsd-' }, ], 'trae/local': [ { kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' }, + { kind: 'agents', destSubpath: 'agents', prefix: 'gsd-' }, ], // ── qwen ───────────────────────────────────────────────────────────────────── @@ -149,13 +164,16 @@ const GOLDEN = { // ── codebuddy ──────────────────────────────────────────────────────────────── // Old switch: no scope branch → local == global. 5b backfill restores this. + // ADR-1235 step 1 (#1763): agents kind added. 'codebuddy/global': [ { kind: 'commands', destSubpath: 'commands', prefix: 'gsd-' }, { kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' }, + { kind: 'agents', destSubpath: 'agents', prefix: 'gsd-' }, ], 'codebuddy/local': [ { kind: 'commands', destSubpath: 'commands', prefix: 'gsd-' }, { kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' }, + { kind: 'agents', destSubpath: 'agents', prefix: 'gsd-' }, ], // ── cline ──────────────────────────────────────────────────────────────────── @@ -360,13 +378,15 @@ describe('resolveRuntimeArtifactLayout — scope defaults to global (descriptor- // ── Non-vacuous check: verify at least one multi-kind runtime ───────────────── describe('resolveRuntimeArtifactLayout — multi-kind runtimes non-vacuous (descriptor-driven)', () => { - test('augment global returns 2 kinds (commands + skills)', () => { + test('augment global returns 3 kinds (commands + skills + agents)', () => { const layout = resolveRuntimeArtifactLayout('augment', FAKE_DIR, 'global'); - assert.strictEqual(layout.kinds.length, 2); + assert.strictEqual(layout.kinds.length, 3); assert.strictEqual(layout.kinds[0].kind, 'commands'); assert.strictEqual(layout.kinds[1].kind, 'skills'); + assert.strictEqual(layout.kinds[2].kind, 'agents'); assert.strictEqual(typeof layout.kinds[0].stage, 'function'); assert.strictEqual(typeof layout.kinds[1].stage, 'function'); + assert.strictEqual(typeof layout.kinds[2].stage, 'function'); }); test('kimi global returns skills then kimi-agents', () => { @@ -378,10 +398,11 @@ describe('resolveRuntimeArtifactLayout — multi-kind runtimes non-vacuous (desc assert.strictEqual(layout.kinds[1].prefix, 'gsd'); }); - test('codebuddy global returns commands then skills', () => { + test('codebuddy global returns commands then skills then agents', () => { const layout = resolveRuntimeArtifactLayout('codebuddy', FAKE_DIR, 'global'); - assert.strictEqual(layout.kinds.length, 2); + assert.strictEqual(layout.kinds.length, 3); assert.strictEqual(layout.kinds[0].kind, 'commands'); assert.strictEqual(layout.kinds[1].kind, 'skills'); + assert.strictEqual(layout.kinds[2].kind, 'agents'); }); }); diff --git a/tests/runtime-artifact-layout.test.cjs b/tests/runtime-artifact-layout.test.cjs index ecb35b783..ccafdfbf1 100644 --- a/tests/runtime-artifact-layout.test.cjs +++ b/tests/runtime-artifact-layout.test.cjs @@ -64,11 +64,11 @@ describe('resolveRuntimeArtifactLayout — claude global', () => { }); describe('resolveRuntimeArtifactLayout — cursor', () => { - test('returns correct layout for cursor — skills + commands kinds (#785)', () => { + test('returns correct layout for cursor — skills + commands + agents kinds (#785, ADR-1235)', () => { const layout = resolveRuntimeArtifactLayout('cursor', FAKE_DIR); assert.strictEqual(layout.runtime, 'cursor'); assert.strictEqual(layout.configDir, FAKE_DIR); - assert.strictEqual(layout.kinds.length, 2); + assert.strictEqual(layout.kinds.length, 3); const skillsKind = layout.kinds.find(k => k.kind === 'skills'); assert.ok(skillsKind, 'must have a skills kind'); @@ -81,6 +81,12 @@ describe('resolveRuntimeArtifactLayout — cursor', () => { assert.strictEqual(commandsKind.destSubpath, 'commands'); assert.strictEqual(commandsKind.prefix, 'gsd-'); assert.strictEqual(typeof commandsKind.stage, 'function'); + + const agentsKind = layout.kinds.find(k => k.kind === 'agents'); + assert.ok(agentsKind, 'must have an agents kind (ADR-1235 §1 descriptor cutover)'); + assert.strictEqual(agentsKind.destSubpath, 'agents'); + assert.strictEqual(agentsKind.prefix, 'gsd-'); + assert.strictEqual(typeof agentsKind.stage, 'function'); }); }); @@ -137,54 +143,84 @@ describe('resolveRuntimeArtifactLayout — antigravity', () => { }); describe('resolveRuntimeArtifactLayout — windsurf', () => { - test('returns local workflow layout for windsurf', () => { + test('returns local workflow + agents layout for windsurf (ADR-1235)', () => { const layout = resolveRuntimeArtifactLayout('windsurf', FAKE_DIR, 'local'); assert.strictEqual(layout.runtime, 'windsurf'); assert.strictEqual(layout.configDir, FAKE_DIR); - assert.strictEqual(layout.kinds.length, 1); - assert.strictEqual(layout.kinds[0].kind, 'commands'); - assert.strictEqual(layout.kinds[0].destSubpath, 'workflows'); - assert.strictEqual(layout.kinds[0].prefix, 'gsd-'); - assert.strictEqual(typeof layout.kinds[0].stage, 'function'); + assert.strictEqual(layout.kinds.length, 2); + + const commandsKind = layout.kinds.find(k => k.kind === 'commands'); + assert.ok(commandsKind, 'must have a commands/workflows kind'); + assert.strictEqual(commandsKind.destSubpath, 'workflows'); + assert.strictEqual(commandsKind.prefix, 'gsd-'); + assert.strictEqual(typeof commandsKind.stage, 'function'); + + const agentsKind = layout.kinds.find(k => k.kind === 'agents'); + assert.ok(agentsKind, 'must have an agents kind (ADR-1235 §1 descriptor cutover)'); + assert.strictEqual(agentsKind.destSubpath, 'agents'); + assert.strictEqual(agentsKind.prefix, 'gsd-'); + assert.strictEqual(typeof agentsKind.stage, 'function'); }); - test('returns empty global layout for windsurf', () => { + test('returns agents-only global layout for windsurf (ADR-1235)', () => { const layout = resolveRuntimeArtifactLayout('windsurf', FAKE_DIR, 'global'); assert.strictEqual(layout.runtime, 'windsurf'); assert.strictEqual(layout.configDir, FAKE_DIR); - assert.strictEqual(layout.kinds.length, 0); + assert.strictEqual(layout.kinds.length, 1); + + const agentsKind = layout.kinds.find(k => k.kind === 'agents'); + assert.ok(agentsKind, 'global windsurf must have agents kind (ADR-1235 §1)'); + assert.strictEqual(agentsKind.destSubpath, 'agents'); + assert.strictEqual(agentsKind.prefix, 'gsd-'); + assert.strictEqual(typeof agentsKind.stage, 'function'); }); }); describe('resolveRuntimeArtifactLayout — augment', () => { - test('returns correct layout for augment (commands + skills)', () => { + test('returns correct layout for augment (commands + skills + agents — ADR-1235)', () => { const layout = resolveRuntimeArtifactLayout('augment', FAKE_DIR); assert.strictEqual(layout.runtime, 'augment'); assert.strictEqual(layout.configDir, FAKE_DIR); - assert.strictEqual(layout.kinds.length, 2); - // commands kind first - assert.strictEqual(layout.kinds[0].kind, 'commands'); - assert.strictEqual(layout.kinds[0].destSubpath, 'commands'); - assert.strictEqual(layout.kinds[0].prefix, 'gsd-'); - assert.strictEqual(typeof layout.kinds[0].stage, 'function'); - // skills kind second - assert.strictEqual(layout.kinds[1].kind, 'skills'); - assert.strictEqual(layout.kinds[1].destSubpath, 'skills'); - assert.strictEqual(layout.kinds[1].prefix, 'gsd-'); - assert.strictEqual(typeof layout.kinds[1].stage, 'function'); + assert.strictEqual(layout.kinds.length, 3); + + const commandsKind = layout.kinds.find(k => k.kind === 'commands'); + assert.ok(commandsKind, 'must have a commands kind'); + assert.strictEqual(commandsKind.destSubpath, 'commands'); + assert.strictEqual(commandsKind.prefix, 'gsd-'); + assert.strictEqual(typeof commandsKind.stage, 'function'); + + const skillsKind = layout.kinds.find(k => k.kind === 'skills'); + assert.ok(skillsKind, 'must have a skills kind'); + assert.strictEqual(skillsKind.destSubpath, 'skills'); + assert.strictEqual(skillsKind.prefix, 'gsd-'); + assert.strictEqual(typeof skillsKind.stage, 'function'); + + const agentsKind = layout.kinds.find(k => k.kind === 'agents'); + assert.ok(agentsKind, 'must have an agents kind (ADR-1235 §1 descriptor cutover)'); + assert.strictEqual(agentsKind.destSubpath, 'agents'); + assert.strictEqual(agentsKind.prefix, 'gsd-'); + assert.strictEqual(typeof agentsKind.stage, 'function'); }); }); describe('resolveRuntimeArtifactLayout — trae', () => { - test('returns correct layout for trae', () => { + test('returns correct layout for trae (skills + agents — ADR-1235)', () => { const layout = resolveRuntimeArtifactLayout('trae', FAKE_DIR); assert.strictEqual(layout.runtime, 'trae'); assert.strictEqual(layout.configDir, FAKE_DIR); - assert.strictEqual(layout.kinds.length, 1); - assert.strictEqual(layout.kinds[0].kind, 'skills'); - assert.strictEqual(layout.kinds[0].destSubpath, 'skills'); - assert.strictEqual(layout.kinds[0].prefix, 'gsd-'); - assert.strictEqual(typeof layout.kinds[0].stage, 'function'); + assert.strictEqual(layout.kinds.length, 2); + + const skillsKind = layout.kinds.find(k => k.kind === 'skills'); + assert.ok(skillsKind, 'must have a skills kind'); + assert.strictEqual(skillsKind.destSubpath, 'skills'); + assert.strictEqual(skillsKind.prefix, 'gsd-'); + assert.strictEqual(typeof skillsKind.stage, 'function'); + + const agentsKind = layout.kinds.find(k => k.kind === 'agents'); + assert.ok(agentsKind, 'must have an agents kind (ADR-1235 §1 descriptor cutover)'); + assert.strictEqual(agentsKind.destSubpath, 'agents'); + assert.strictEqual(agentsKind.prefix, 'gsd-'); + assert.strictEqual(typeof agentsKind.stage, 'function'); }); }); @@ -237,21 +273,29 @@ describe('resolveRuntimeArtifactLayout — hermes', () => { }); describe('resolveRuntimeArtifactLayout — codebuddy', () => { - test('returns correct layout for codebuddy (commands + skills — #789)', () => { + test('returns correct layout for codebuddy (commands + skills + agents — #789, ADR-1235)', () => { const layout = resolveRuntimeArtifactLayout('codebuddy', FAKE_DIR); assert.strictEqual(layout.runtime, 'codebuddy'); assert.strictEqual(layout.configDir, FAKE_DIR); - assert.strictEqual(layout.kinds.length, 2); - // commands kind first - assert.strictEqual(layout.kinds[0].kind, 'commands'); - assert.strictEqual(layout.kinds[0].destSubpath, 'commands'); - assert.strictEqual(layout.kinds[0].prefix, 'gsd-'); - assert.strictEqual(typeof layout.kinds[0].stage, 'function'); - // skills kind second - assert.strictEqual(layout.kinds[1].kind, 'skills'); - assert.strictEqual(layout.kinds[1].destSubpath, 'skills'); - assert.strictEqual(layout.kinds[1].prefix, 'gsd-'); - assert.strictEqual(typeof layout.kinds[1].stage, 'function'); + assert.strictEqual(layout.kinds.length, 3); + + const commandsKind = layout.kinds.find(k => k.kind === 'commands'); + assert.ok(commandsKind, 'must have a commands kind'); + assert.strictEqual(commandsKind.destSubpath, 'commands'); + assert.strictEqual(commandsKind.prefix, 'gsd-'); + assert.strictEqual(typeof commandsKind.stage, 'function'); + + const skillsKind = layout.kinds.find(k => k.kind === 'skills'); + assert.ok(skillsKind, 'must have a skills kind'); + assert.strictEqual(skillsKind.destSubpath, 'skills'); + assert.strictEqual(skillsKind.prefix, 'gsd-'); + assert.strictEqual(typeof skillsKind.stage, 'function'); + + const agentsKind = layout.kinds.find(k => k.kind === 'agents'); + assert.ok(agentsKind, 'must have an agents kind (ADR-1235 §1 descriptor cutover)'); + assert.strictEqual(agentsKind.destSubpath, 'agents'); + assert.strictEqual(agentsKind.prefix, 'gsd-'); + assert.strictEqual(typeof agentsKind.stage, 'function'); }); }); diff --git a/tests/runtime-homes-descriptor-drive.test.cjs b/tests/runtime-homes-descriptor-drive.test.cjs index 074bd321a..6c04af418 100644 --- a/tests/runtime-homes-descriptor-drive.test.cjs +++ b/tests/runtime-homes-descriptor-drive.test.cjs @@ -178,7 +178,7 @@ describe('descriptor-driven equivalence: env-var overrides', () => { process.env['COPILOT_CONFIG_DIR'] = '/custom/copilot-dir'; process.env['COPILOT_HOME'] = '/should/not/win'; try { - assert.strictEqual(getGlobalConfigDir('copilot'), '/custom/copilot-dir'); + assert.strictEqual(String(getGlobalConfigDir('copilot')).replace(/\\/g, '/'), '/custom/copilot-dir'); } finally { restoreEnvKeys(saved); } @@ -188,7 +188,7 @@ describe('descriptor-driven equivalence: env-var overrides', () => { const saved = clearAllEnvKeys(); process.env['COPILOT_HOME'] = '/custom/copilot-home'; try { - assert.strictEqual(getGlobalConfigDir('copilot'), '/custom/copilot-home'); + assert.strictEqual(String(getGlobalConfigDir('copilot')).replace(/\\/g, '/'), '/custom/copilot-home'); } finally { restoreEnvKeys(saved); } @@ -219,7 +219,7 @@ describe('descriptor-driven equivalence: xdg runtimes (opencode, kilo)', () => { const saved = clearAllEnvKeys(); process.env['OPENCODE_CONFIG'] = '/home/u/cfg/opencode.json'; try { - assert.strictEqual(getGlobalConfigDir('opencode'), '/home/u/cfg'); + assert.strictEqual(String(getGlobalConfigDir('opencode')).replace(/\\/g, '/'), '/home/u/cfg'); } finally { restoreEnvKeys(saved); } @@ -230,7 +230,7 @@ describe('descriptor-driven equivalence: xdg runtimes (opencode, kilo)', () => { process.env['OPENCODE_CONFIG_DIR'] = '/dir/wins'; process.env['OPENCODE_CONFIG'] = '/file/loses.json'; try { - assert.strictEqual(getGlobalConfigDir('opencode'), '/dir/wins'); + assert.strictEqual(String(getGlobalConfigDir('opencode')).replace(/\\/g, '/'), '/dir/wins'); } finally { restoreEnvKeys(saved); } @@ -241,7 +241,7 @@ describe('descriptor-driven equivalence: xdg runtimes (opencode, kilo)', () => { process.env['OPENCODE_CONFIG'] = '/cfg/opencode.json'; process.env['XDG_CONFIG_HOME'] = '/xdg/should/lose'; try { - assert.strictEqual(getGlobalConfigDir('opencode'), '/cfg'); + assert.strictEqual(String(getGlobalConfigDir('opencode')).replace(/\\/g, '/'), '/cfg'); } finally { restoreEnvKeys(saved); } @@ -272,7 +272,7 @@ describe('descriptor-driven equivalence: xdg runtimes (opencode, kilo)', () => { const saved = clearAllEnvKeys(); process.env['KILO_CONFIG'] = '/home/u/cfg/kilo.json'; try { - assert.strictEqual(getGlobalConfigDir('kilo'), '/home/u/cfg'); + assert.strictEqual(String(getGlobalConfigDir('kilo')).replace(/\\/g, '/'), '/home/u/cfg'); } finally { restoreEnvKeys(saved); } @@ -283,7 +283,7 @@ describe('descriptor-driven equivalence: xdg runtimes (opencode, kilo)', () => { process.env['KILO_CONFIG_DIR'] = '/dir/wins'; process.env['KILO_CONFIG'] = '/file/loses.json'; try { - assert.strictEqual(getGlobalConfigDir('kilo'), '/dir/wins'); + assert.strictEqual(String(getGlobalConfigDir('kilo')).replace(/\\/g, '/'), '/dir/wins'); } finally { restoreEnvKeys(saved); } @@ -294,7 +294,7 @@ describe('descriptor-driven equivalence: xdg runtimes (opencode, kilo)', () => { process.env['KILO_CONFIG'] = '/cfg/kilo.json'; process.env['XDG_CONFIG_HOME'] = '/xdg/should/lose'; try { - assert.strictEqual(getGlobalConfigDir('kilo'), '/cfg'); + assert.strictEqual(String(getGlobalConfigDir('kilo')).replace(/\\/g, '/'), '/cfg'); } finally { restoreEnvKeys(saved); } @@ -735,10 +735,10 @@ describe('descriptor-driven equivalence: generic-agents-root kimi probe hit/miss describe('descriptor-driven equivalence: explicitDir short-circuit', () => { test('explicitDir absolute path returned as-is (any runtime)', () => { - assert.strictEqual(getGlobalConfigDir('claude', '/tmp/explicit'), '/tmp/explicit'); - assert.strictEqual(getGlobalConfigDir('opencode', '/tmp/explicit'), '/tmp/explicit'); - assert.strictEqual(getGlobalConfigDir('kimi', '/tmp/explicit'), '/tmp/explicit'); - assert.strictEqual(getGlobalConfigDir('grok', '/tmp/explicit'), '/tmp/explicit'); + assert.strictEqual(String(getGlobalConfigDir('claude', '/tmp/explicit')).replace(/\\/g, '/'), '/tmp/explicit'); + assert.strictEqual(String(getGlobalConfigDir('opencode', '/tmp/explicit')).replace(/\\/g, '/'), '/tmp/explicit'); + assert.strictEqual(String(getGlobalConfigDir('kimi', '/tmp/explicit')).replace(/\\/g, '/'), '/tmp/explicit'); + assert.strictEqual(String(getGlobalConfigDir('grok', '/tmp/explicit')).replace(/\\/g, '/'), '/tmp/explicit'); }); test('explicitDir with ~ is expanded', () => { @@ -750,7 +750,7 @@ describe('descriptor-driven equivalence: explicitDir short-circuit', () => { test('explicitDir wins even when env var is set', () => { withEnv({ CLAUDE_CONFIG_DIR: '/should/not/win' }, () => { - assert.strictEqual(getGlobalConfigDir('claude', '/explicit/wins'), '/explicit/wins'); + assert.strictEqual(String(getGlobalConfigDir('claude', '/explicit/wins')).replace(/\\/g, '/'), '/explicit/wins'); }); }); }); @@ -769,7 +769,7 @@ describe('descriptor-driven equivalence: grok (not in registry)', () => { test('grok: GROK_AGENTS_HOME override', () => { withEnv({ GROK_AGENTS_HOME: '/custom/grok-agents' }, () => { - assert.strictEqual(getGlobalConfigDir('grok'), '/custom/grok-agents'); + assert.strictEqual(String(getGlobalConfigDir('grok')).replace(/\\/g, '/'), '/custom/grok-agents'); }); }); @@ -794,7 +794,7 @@ describe('descriptor-driven equivalence: unknown runtime fallback', () => { test('unknown runtime → CLAUDE_CONFIG_DIR if set', () => { withEnv({ CLAUDE_CONFIG_DIR: '/custom/claude-for-unknown' }, () => { - assert.strictEqual(getGlobalConfigDir('no-such-runtime'), '/custom/claude-for-unknown'); + assert.strictEqual(String(getGlobalConfigDir('no-such-runtime')).replace(/\\/g, '/'), '/custom/claude-for-unknown'); }); }); }); diff --git a/tests/runtime-launcher-parity.test.cjs b/tests/runtime-launcher-parity.test.cjs index 6aa0b4b8e..bc40ceabb 100644 --- a/tests/runtime-launcher-parity.test.cjs +++ b/tests/runtime-launcher-parity.test.cjs @@ -40,7 +40,7 @@ const SNIPPET_FILE = path.join(WORKFLOWS_DIR, '_runtime-launcher.snippet.sh'); */ function expectedPreamble() { const raw = fs.readFileSync(SNIPPET_FILE, 'utf8'); - const lines = raw.split('\n'); + const lines = raw.split(/\r?\n/); // Strip trailing empty element produced by a trailing newline. const content = lines[lines.length - 1] === '' ? lines.slice(0, -1) : lines; assert.ok(content.length >= 1, `_runtime-launcher.snippet.sh must not be empty`); @@ -55,7 +55,7 @@ function expectedPreamble() { * Handles both column-0 fences (```bash) and indented fences ( ```bash). */ function extractShellBlocks(content) { - const allLines = content.split('\n'); + const allLines = content.split(/\r?\n/); const blocks = []; let inBlock = false; let blockLang = null; @@ -521,7 +521,7 @@ describe('runtime-launcher-parity (#373)', () => { `_runtime-launcher.snippet.sh must not contain the literal "/gsd-tools" substring. ` + `Use bin/\${_GSD_SHIM_NAME} indirection to keep the /gsd[:-] scanner from ` + `misreading it as a slash-command stub. Found in snippet:\n` + - snippetContent.split('\n').filter((l) => l.includes('/gsd-tools')).join('\n'), + snippetContent.split(/\r?\n/).filter((l) => l.includes('/gsd-tools')).join('\n'), ); // (F2) workflows/do.md must not contain the literal substring /gsd-tools @@ -533,7 +533,7 @@ describe('runtime-launcher-parity (#373)', () => { const doMdPath = path.join(WORKFLOWS_DIR, 'do.md'); const doMdContent = fs.readFileSync(doMdPath, 'utf8'); const offendingLines = doMdContent - .split('\n') + .split(/\r?\n/) .filter((l) => /\/gsd-tools/.test(l)); assert.deepStrictEqual( offendingLines, diff --git a/tests/secret-scan-lint.security.test.cjs b/tests/secret-scan-lint.security.test.cjs index 8dd6a4ff6..49f551567 100644 --- a/tests/secret-scan-lint.security.test.cjs +++ b/tests/secret-scan-lint.security.test.cjs @@ -113,7 +113,7 @@ describe('secret-scan-lint.sh script exists and is executable', { skip: IS_WINDO }); test('lint script has bash shebang', () => { - const firstLine = fs.readFileSync(LINT_SCRIPT, 'utf-8').split('\n')[0]; + const firstLine = fs.readFileSync(LINT_SCRIPT, 'utf-8').split(/\r?\n/)[0]; assert.ok( firstLine.startsWith('#!/usr/bin/env bash') || firstLine.startsWith('#!/bin/bash'), `${LINT_SCRIPT} missing bash shebang: ${firstLine}` diff --git a/tests/secure-phase.test.cjs b/tests/secure-phase.test.cjs index 01e0826df..109faa40a 100644 --- a/tests/secure-phase.test.cjs +++ b/tests/secure-phase.test.cjs @@ -380,7 +380,7 @@ describe('SECURE: VALIDATION.md security columns', () => { test('both columns appear in the Per-Task Verification Map table', () => { const content = fs.readFileSync(valPath, 'utf-8'); // Find the table header row containing both columns - const lines = content.split('\n'); + const lines = content.split(/\r?\n/); const headerLine = lines.find( line => line.includes('Threat Ref') && line.includes('Secure Behavior') ); @@ -516,7 +516,7 @@ describe('SECURE: per-threat severity gate (#1626)', () => { // The old unconditional language said "phase must not ship" without a severity qualifier. // After the fix, every "phase must not ship" must be paired with a severity condition. // Find all occurrences of "must not ship" and verify none appear without "severity" nearby. - const lines = content.split('\n'); + const lines = content.split(/\r?\n/); for (const line of lines) { if (line.includes('must not ship') && !line.includes('severity')) { assert.fail( diff --git a/tests/security-scan.security.test.cjs b/tests/security-scan.security.test.cjs index ac93714b0..e1dec6d03 100644 --- a/tests/security-scan.security.test.cjs +++ b/tests/security-scan.security.test.cjs @@ -92,7 +92,7 @@ describe('security scan scripts exist and are executable', () => { }); test(`${name} script has bash shebang`, () => { - const firstLine = fs.readFileSync(scriptPath, 'utf-8').split('\n')[0]; + const firstLine = fs.readFileSync(scriptPath, 'utf-8').split(/\r?\n/)[0]; assert.ok( firstLine.startsWith('#!/usr/bin/env bash') || firstLine.startsWith('#!/bin/bash'), `${scriptPath} missing bash shebang: ${firstLine}` @@ -563,7 +563,7 @@ describe('security-scan.yml workflow', () => { test('workflow does not use direct github context in run commands', () => { const content = fs.readFileSync(workflowPath, 'utf-8'); // Extract only run: blocks and check they don't contain ${{ }} - const runBlocks = content.match(/run:\s*\|?\s*\n([\s\S]*?)(?=\n\s*-|\n\s*\w+:|Z)/g) || []; + const runBlocks = content.match(/run:\s*\|?\s*\r?\n([\s\S]*?)(?=\r?\n\s*-|\r?\n\s*\w+:|Z)/g) || []; for (const block of runBlocks) { assert.ok( !block.includes('${{'), diff --git a/tests/spawn-liveness-banner.test.cjs b/tests/spawn-liveness-banner.test.cjs index fa027c096..cf212f8d9 100644 --- a/tests/spawn-liveness-banner.test.cjs +++ b/tests/spawn-liveness-banner.test.cjs @@ -37,7 +37,7 @@ const LIVENESS_PHRASE = 'runs in a subagent'; // But NOT: // "◆ Planner wrote N plan(s)..." → not matched (no "spawn" word) // "◆ Research phase enabled" → not matched (no "spawn" word) -const SPAWN_BANNER_RE = /◆[^\n]*\bspawning?\b/i; +const SPAWN_BANNER_RE = /◆[^\r\n]*\bspawning?\b/i; function findMdFiles(dir) { const entries = fs.readdirSync(dir, { withFileTypes: true }); @@ -60,7 +60,7 @@ describe('spawn-liveness-banner', () => { for (const filePath of mdFiles) { const content = fs.readFileSync(filePath, 'utf-8'); - const lines = content.split('\n'); + const lines = content.split(/\r?\n/); const rel = path.relative(WORKFLOWS_DIR, filePath); for (let i = 0; i < lines.length; i++) { diff --git a/tests/state.test.cjs b/tests/state.test.cjs index 5025075ec..c39fbfd92 100644 --- a/tests/state.test.cjs +++ b/tests/state.test.cjs @@ -1366,7 +1366,7 @@ describe('cmdStateResolveBlocker (state resolve-blocker)', () => { assert.ok(!updated.includes('- Single blocker'), 'resolved blocker should be removed'); // Section should contain "None" placeholder, not be empty - const sectionMatch = updated.match(/## Blockers\n([\s\S]*?)(?=\n##|$)/i); + const sectionMatch = updated.match(/## Blockers\r?\n([\s\S]*?)(?=\r?\n##|$)/i); assert.ok(sectionMatch, 'Blockers section should still exist'); assert.ok(sectionMatch[1].includes('None'), 'Blockers section should contain None placeholder'); }); @@ -1680,7 +1680,7 @@ Progress: [..........] 0% ); // Extract the Current Position section - const posMatch = content.match(/## Current Position\s*\n([\s\S]*?)(?=\n##|$)/i); + const posMatch = content.match(/## Current Position\s*\r?\n([\s\S]*?)(?=\r?\n##|$)/i); assert.ok(posMatch, 'Current Position section should exist'); const posSection = posMatch[1]; @@ -1742,7 +1742,7 @@ Progress: [..........] 0% const content = fs.readFileSync( path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8' ); - const posMatch = content.match(/## Current Position\s*\n([\s\S]*?)(?=\n##|$)/i); + const posMatch = content.match(/## Current Position\s*\r?\n([\s\S]*?)(?=\r?\n##|$)/i); assert.ok(posMatch, 'Current Position section should exist after advance-plan'); const posSection = posMatch[1]; @@ -2240,7 +2240,7 @@ describe('updatePerformanceMetricsSection', () => { ].join('\n'); const statePath = path.join(tmpDir, '.planning', 'STATE.md'); // Force CRLF line endings across the whole STATE.md (Windows / hand-edited). - fs.writeFileSync(statePath, content.replace(/\n/g, '\r\n'), 'utf8'); + fs.writeFileSync(statePath, content.replace(/\r?\n/g, '\r\n'), 'utf8'); const phaseDir = path.join(tmpDir, '.planning', 'phases', '07-crlf'); fs.mkdirSync(phaseDir, { recursive: true }); @@ -2464,7 +2464,7 @@ Progress: [##########] 20% ); // Current Position Status: line must also be "Ready to execute" - const posMatch = stateContent.match(/## Current Position\s*\n([\s\S]*?)(?=\n##|$)/i); + const posMatch = stateContent.match(/## Current Position\s*\r?\n([\s\S]*?)(?=\r?\n##|$)/i); assert.ok(posMatch, 'Current Position section not found'); const posStatusMatch = posMatch[1].match(/^Status:\s*(.+)/m); assert.ok(posStatusMatch, 'Status field not found in Current Position section'); @@ -2519,7 +2519,7 @@ Progress: [##########] 20% const stateContent = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); // Locate the Current Position section and verify the Status line there. - const posMatch = stateContent.match(/## Current Position\s*\n([\s\S]*?)(?=\n##|$)/i); + const posMatch = stateContent.match(/## Current Position\s*\r?\n([\s\S]*?)(?=\r?\n##|$)/i); assert.ok(posMatch, 'Current Position section not found'); const posStatusMatch = posMatch[1].match(/^Status:\s*(.+)/m); assert.ok(posStatusMatch, 'Status field not found in Current Position section'); @@ -2678,7 +2678,7 @@ describe('state sync command', () => { const afterSecond = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); // Strip frontmatter timestamps which will differ - const stripTimestamps = (s) => s.replace(/last_updated:.*\n/g, '').replace(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/g, 'TS'); + const stripTimestamps = (s) => s.replace(/last_updated:.*\r?\n/g, '').replace(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/g, 'TS'); assert.strictEqual(stripTimestamps(afterFirst), stripTimestamps(afterSecond), 'Two syncs should produce same result'); }); @@ -3119,7 +3119,7 @@ describe('state add-roadmap-evolution (bug #1140)', () => { // Body of `## Accumulated Context` bounded by the next h2 (or EOF), so // placement assertions prove a subsection sits INSIDE that section. const accumulatedContextBody = (state) => { - const m = state.match(/##\s*Accumulated Context\s*\n([\s\S]*?)(?=\n##[^#]|$)/); + const m = state.match(/##\s*Accumulated Context\s*\r?\n([\s\S]*?)(?=\n##[^#]|$)/); return m ? m[1] : null; }; @@ -3284,7 +3284,7 @@ describe('state add-roadmap-evolution (bug #1140)', () => { const state = readState(tmpDir); assert.ok(state.includes('- Phase 9 edited: line one line two line three'), `note not flattened:\n${state}`); - assert.ok(!/\n\s*line two/.test(state), 'continuation lines must not spill outside the bullet'); + assert.ok(!/\r?\n\s*line two/.test(state), 'continuation lines must not spill outside the bullet'); const second = runGsdTools( ['state', 'add-roadmap-evolution', '--phase', '9', '--action', 'edited', '--note-file', notePath], @@ -3722,7 +3722,7 @@ describe('regressions: table-format STATE.md (#1162)', () => { }); test('CRLF line endings in table format are handled', () => { - const content = buildTableFormatState({ status: 'Ready to plan' }).replace(/\n/g, '\r\n'); + const content = buildTableFormatState({ status: 'Ready to plan' }).replace(/\r?\n/g, '\r\n'); fs.writeFileSync(statePath, content); const result = runGsdTools(['state', 'update', 'Status', 'Ready to execute'], tmpDir); @@ -4021,7 +4021,7 @@ describe('#1255 — begin/complete-phase advance status for pipe-table STATE.md' const after = fs.readFileSync(path.join(dir, '.planning', 'STATE.md'), 'utf8'); // Extract the ## Current Position section only, to avoid matching Configuration rows - const cpMatch = after.match(/##\s*Current Position\s*\n([\s\S]*?)(?=\n##|$)/i); + const cpMatch = after.match(/##\s*Current Position\s*\r?\n([\s\S]*?)(?=\r?\n##|$)/i); assert.ok(cpMatch, '## Current Position section must exist'); const cpSection = cpMatch[1]; @@ -4095,7 +4095,7 @@ describe('#1255 — begin/complete-phase advance status for pipe-table STATE.md' const after = fs.readFileSync(path.join(dir, '.planning', 'STATE.md'), 'utf8'); // Extract the ## Current Position section only, to avoid matching Configuration rows - const cpMatch = after.match(/##\s*Current Position\s*\n([\s\S]*?)(?=\n##|$)/i); + const cpMatch = after.match(/##\s*Current Position\s*\r?\n([\s\S]*?)(?=\r?\n##|$)/i); assert.ok(cpMatch, '## Current Position section must exist'); const cpSection = cpMatch[1]; diff --git a/tests/subagent-timeout.test.cjs b/tests/subagent-timeout.test.cjs index 9db81a8f3..5e4198bee 100644 --- a/tests/subagent-timeout.test.cjs +++ b/tests/subagent-timeout.test.cjs @@ -103,7 +103,7 @@ describe('map-codebase workflow references configurable timeout (#1472)', () => const content = fs.readFileSync(workflowPath, 'utf8'); // The timeout line should reference the config variable, not a hardcoded value - const timeoutLines = content.split('\n').filter(l => l.includes('timeout:')); + const timeoutLines = content.split(/\r?\n/).filter(l => l.includes('timeout:')); for (const line of timeoutLines) { assert.ok( !line.match(/timeout:\s*300000\s*$/), diff --git a/tests/windows-test-parity-guard.test.cjs b/tests/windows-test-parity-guard.test.cjs deleted file mode 100644 index fd8eb30f0..000000000 --- a/tests/windows-test-parity-guard.test.cjs +++ /dev/null @@ -1,203 +0,0 @@ -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -/** - * Named-set allowlist guard against Windows-test-parity regressions. - * - * PR #3649 cleared ~270 Windows-only test failures from the chunking fix - * in #3597 surfaced. Each cluster reduced to a handful of repeating - * patterns. This guard prevents the patterns from being re-introduced. - * - * Strategy (updated from integer-count ratchet): each rule's known offenders - * are enumerated by filename in a frozen KNOWN_OFFENDERS set. The guard uses - * the shared assertWithinAllowlist primitive (scripts/lib/allowlist-ratchet.cjs) - * which enforces BOTH directions: - * - Novel offenders (current \ known) → fail immediately. - * - Stale allowlist entries (known \ current) → also fail, forcing the - * allowlist to shrink as defects are fixed (ratchet-DOWN enforcement). - * - * When you fix an existing offender, you MUST remove its entry from - * KNOWN_OFFENDERS — the guard will fail on stale entries to enforce progress. - * When CI breaks because a new file introduced an anti-pattern, fix the - * anti-pattern — do not just add the filename to the set to silence the guard. - * - * rmSync teardown safety is now enforced at write-time by the ESLint rule - * local/no-raw-rmsync-in-tests (see issue #597); it is no longer ratcheted here. - * - * Scope: tests/ only. Production-code Windows-compat is enforced via - * behavioural tests (see no-unconditional-win32-skip.test.cjs). - */ - -// allow-test-rule: structural-regression-guard - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const { assertWithinAllowlist } = require('../scripts/lib/allowlist-ratchet.cjs'); - -const TESTS_DIR = path.join(__dirname); -const SELF = path.basename(__filename); - -// ── Known offenders after PR #3649 batch (named-set allowlist) ─────────── -// These are the files that matched each anti-pattern at the time of writing. -// A test fails when a file NOT in the set starts matching (novel regression), -// OR when a file in the set stops matching (stale entry — must be pruned). -// Edit this object to update the allowlists: -// edit KNOWN_OFFENDERS in tests/windows-test-parity-guard.test.cjs -const KNOWN_OFFENDERS = Object.freeze({ - splitNewlineOnFileContent: new Set([ - 'release-coverage-scope.test.cjs', - 'secret-scan-lint.security.test.cjs', - 'security-scan.security.test.cjs', - ]), - fenceRegexLiteralNewline: new Set([ - 'bug-2995-post-install-script-paths.test.cjs', - 'security-scan.security.test.cjs', - ]), - frontmatterAnchorLiteralNewline: new Set([ - 'bug-1967-cache-invalidation.test.cjs', - 'bug-2643-skill-frontmatter-name.test.cjs', - 'bug-2808-skill-hyphen-name.test.cjs', - 'bug-3168-task-to-agent-rename.test.cjs', - 'qwen-skills-migration.test.cjs', - ]), - hardcodedTmpToFsCall: new Set([ - // (none at time of writing) - ]), - bareNpmExecWithoutShell: new Set([ - // (none at time of writing) - ]), - stubsHomeNoUserProfile: new Set([ - 'bug-130-finishinstall-opencode-testmode.test.cjs', - 'bug-2794-opencode-model-profile-overrides.test.cjs', - 'claude-md.test.cjs', - 'feat-443-effort-install-wiring.install.test.cjs', - 'issue-2517-runtime-aware-profiles.test.cjs', - ]), -}); - -function listTestFiles() { - return fs.readdirSync(TESTS_DIR) - .filter((f) => /\.(test|spec)\.cjs$/.test(f)) - .filter((f) => f !== SELF) - .map((f) => path.join(TESTS_DIR, f)); -} - -function readFileText(filePath) { - return fs.readFileSync(filePath, 'utf8'); -} - -// Strip line comments and block comments before pattern matching to avoid -// false-positives in commentary describing the very pattern we forbid. -function stripComments(text) { - return text - .replace(/\/\*[\s\S]*?\*\//g, '') - .replace(/(^|[^:])\/\/[^\n]*/g, '$1'); -} - -function countMatchingFiles(predicate) { - let count = 0; - const offenders = []; - for (const file of listTestFiles()) { - const text = stripComments(readFileText(file)); - if (predicate(text, file)) { - count += 1; - offenders.push(path.basename(file)); - } - } - return { count, offenders }; -} - -const PRUNE_HINT = 'edit KNOWN_OFFENDERS in tests/windows-test-parity-guard.test.cjs'; - -describe('Windows test-parity lint guards (named-set allowlist: PR #3649)', () => { - // ── G1 — CRLF: file-content split on literal '\n' ───────────────────── - test('split-on-newline after readFileSync (use /\\r?\\n/)', () => { - const { offenders } = countMatchingFiles((text) => { - return /\.readFileSync\s*\([^)]*\)[^;]*\.split\(\s*['"]\\n['"]\s*\)/.test(text); - }); - assertWithinAllowlist({ - label: 'splitNewlineOnFileContent', - current: offenders, - known: KNOWN_OFFENDERS.splitNewlineOnFileContent, - fail: assert.fail, - pruneHint: PRUNE_HINT, - }); - }); - - // ── G2 — CRLF: ```bash|sh\n fence regex on file content ────────────── - test('markdown-fence regex with literal \\n after ```bash/sh', () => { - const { offenders } = countMatchingFiles((text) => { - return /\/[^/]*```(?:bash|sh)\\n[^/]*\//.test(text); - }); - assertWithinAllowlist({ - label: 'fenceRegexLiteralNewline', - current: offenders, - known: KNOWN_OFFENDERS.fenceRegexLiteralNewline, - fail: assert.fail, - pruneHint: PRUNE_HINT, - }); - }); - - // ── G3 — CRLF: frontmatter regex with literal '\n' ──────────────────── - test('frontmatter regex anchors on /^---\\n/', () => { - const { offenders } = countMatchingFiles((text) => { - return /\/\^---\\n/.test(text); - }); - assertWithinAllowlist({ - label: 'frontmatterAnchorLiteralNewline', - current: offenders, - known: KNOWN_OFFENDERS.frontmatterAnchorLiteralNewline, - fail: assert.fail, - pruneHint: PRUNE_HINT, - }); - }); - - // ── G4 — POSIX-tmp: hardcoded '/tmp/' literal passed to fs.* ───────── - test('fs.* call receives a hardcoded "/tmp/..." literal', () => { - const { offenders } = countMatchingFiles((text) => { - return /\bfs\.[A-Za-z]+\s*\([^)]*['"]\/tmp\/[^'"]+['"][^)]*\)/.test(text); - }); - assertWithinAllowlist({ - label: 'hardcodedTmpToFsCall', - current: offenders, - known: KNOWN_OFFENDERS.hardcodedTmpToFsCall, - fail: assert.fail, - pruneHint: PRUNE_HINT, - }); - }); - - // ── G5 — npm.cmd: bare 'npm' to exec*Sync without shell:true ───────── - test('bare npm exec without shell-true Windows fallback', () => { - const { offenders } = countMatchingFiles((text) => { - const re = /\b(?:execFileSync|spawnSync)\s*\(\s*['"]npm['"]\s*,[^)]*\)/g; - const matches = text.match(re) || []; - return matches.some((m) => - !/shell\s*:\s*true/.test(m) && !/shell\s*:\s*isWindows/.test(m), - ); - }); - assertWithinAllowlist({ - label: 'bareNpmExecWithoutShell', - current: offenders, - known: KNOWN_OFFENDERS.bareNpmExecWithoutShell, - fail: assert.fail, - pruneHint: PRUNE_HINT, - }); - }); - - // ── G6 — Test stubs HOME without USERPROFILE ───────────────────────── - test('test stubs process.env.HOME but never references USERPROFILE', () => { - const { offenders } = countMatchingFiles((text) => { - return /process\.env\.HOME\s*=\s*/.test(text) && !/USERPROFILE/.test(text); - }); - assertWithinAllowlist({ - label: 'stubsHomeNoUserProfile', - current: offenders, - known: KNOWN_OFFENDERS.stubsHomeNoUserProfile, - fail: assert.fail, - pruneHint: PRUNE_HINT, - }); - }); -}); diff --git a/tests/workflow-size-baseline.json b/tests/workflow-size-baseline.json index 6e785faa5..a7bd5fdfd 100644 --- a/tests/workflow-size-baseline.json +++ b/tests/workflow-size-baseline.json @@ -8,7 +8,7 @@ "audit-fix.md": 10988, "audit-milestone.md": 17637, "audit-uat.md": 7425, - "autonomous.md": 42675, + "autonomous.md": 42747, "check-todos.md": 9431, "cleanup.md": 9897, "code-review-fix.md": 23890, @@ -40,7 +40,7 @@ "list-phase-assumptions.md": 4305, "list-seeds.md": 6943, "list-workspaces.md": 5655, - "manager.md": 26966, + "manager.md": 27258, "map-codebase.md": 20789, "milestone-summary.md": 11774, "mvp-phase.md": 13582, diff --git a/tests/workspace.test.cjs b/tests/workspace.test.cjs index a9319853e..1e1a57af9 100644 --- a/tests/workspace.test.cjs +++ b/tests/workspace.test.cjs @@ -331,7 +331,7 @@ describe('workspace command files', () => { const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/); assert.ok(fmMatch, `${path.basename(filePath)} must start with a YAML frontmatter block`); const fm = {}; - for (const rawLine of fmMatch[1].split('\n')) { + for (const rawLine of fmMatch[1].split(/\r?\n/)) { // Explicit \r strip: split('\n') on CRLF content leaves a trailing // \r on every line, which the value regex pulls into `kv[2]` and trim // is enough for most values — but be defensive so future keys with @@ -358,7 +358,7 @@ describe('workspace command files', () => { .map((m) => m[1]); const targets = []; for (const blk of blocks) { - for (const line of blk.split('\n')) { + for (const line of blk.split(/\r?\n/)) { const t = line.trim(); if (!t.startsWith('@')) continue; // Normalize away the home-prefix and the `.claude/gsd-core/` root diff --git a/tests/worktree-cleanup.test.cjs b/tests/worktree-cleanup.test.cjs index 467540e41..fd60b60cb 100644 --- a/tests/worktree-cleanup.test.cjs +++ b/tests/worktree-cleanup.test.cjs @@ -53,7 +53,7 @@ function extractNamedBlock(markdown, blockName) { */ function extractFencedCodeBlocks(markdown) { const blocks = []; - const lines = markdown.split('\n'); + const lines = markdown.split(/\r?\n/); let inFence = false; let fenceLang = ''; let buffer = []; @@ -83,7 +83,7 @@ function extractFencedCodeBlocks(markdown) { */ function shellStatements(script) { const statements = []; - const lines = script.split('\n'); + const lines = script.split(/\r?\n/); for (let raw of lines) { const line = raw.replace(/#.*$/, '').trim(); if (!line) continue; @@ -219,7 +219,7 @@ describe('bug #2924: worktree HEAD attachment + destructive recovery', () => { // negated/opt-out context (e.g. "Do NOT pass --no-verify"); reject // any sentence whose first verb is "Use --no-verify". const sentences = block - .replace(/\n+/g, ' ') + .replace(/\r?\n+/g, ' ') .split(/(?<=[.!?])\s+/); for (const sentence of sentences) { if (!sentence.includes('--no-verify')) continue; @@ -253,7 +253,7 @@ describe('bug #2924: worktree HEAD attachment + destructive recovery', () => { assert.notStrictEqual(endIdx, -1, 'parallel-executor sub-section terminator must exist'); const subBlock = block.slice(headingIdx, endIdx); assert.ok(subBlock.length > 0, 'sub-section must have content'); - const sentences = subBlock.replace(/\n+/g, ' ').split(/(?<=[.!?])\s+/); + const sentences = subBlock.replace(/\r?\n+/g, ' ').split(/(?<=[.!?])\s+/); for (const sentence of sentences) { if (!sentence.includes('--no-verify')) continue; const lower = sentence.toLowerCase(); @@ -448,10 +448,10 @@ describe('bug #2924: worktree HEAD attachment + destructive recovery', () => { const idx = content.indexOf('Parallel agents'); assert.notStrictEqual(idx, -1, 'must contain a "Parallel agents" callout'); const section = content.slice(idx); - const endMatch = section.slice(1).match(/\n#{1,6}\s/); + const endMatch = section.slice(1).match(/\r?\n#{1,6}\s/); assert.ok(endMatch, 'Parallel agents section must terminate at the next heading'); const tail = section.slice(0, 1 + endMatch.index); - const sentences = tail.replace(/\n+/g, ' ').split(/(?<=[.!?])\s+/); + const sentences = tail.replace(/\r?\n+/g, ' ').split(/(?<=[.!?])\s+/); for (const sentence of sentences) { if (!sentence.includes('--no-verify')) continue; const lower = sentence.toLowerCase();