Merge branch 'next' into fix/2056-plan-phase-foreign-prefix
This commit is contained in:
5
.changeset/2073-antigravity-reviewer-block.md
Normal file
5
.changeset/2073-antigravity-reviewer-block.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 2109
|
||||
---
|
||||
**`/gsd-review`'s Antigravity CLI reviewer no longer fails silently on large prompts, unavailable pinned models, or pre-session stalls** — the `agy` invocation now uses a file-reference prompt to avoid exec arg-list overflow, is wrapped in an external wall-clock `timeout` paired with `--print-timeout` because `--print-timeout` cannot fire before `agy` creates a session, passes `--model` from `review.models.agy` when set as an escape hatch for a 404'd pinned model, and its empty-output stub now surfaces an `agy` cli.log diagnostic instead of a bare generic message. Supersedes the #687 "no external killer / inline `$(cat)`" contract, which predated `agy` gaining `--model` and predated its own guidance to pair `--print-timeout` with a terminal timeout. (#2073)
|
||||
5
.changeset/2086-eos-claude-imperative-adapter.md
Normal file
5
.changeset/2086-eos-claude-imperative-adapter.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Changed
|
||||
pr: 2106
|
||||
---
|
||||
**Internal: Claude Code's installer is now driven through the public Host-Integration Interface (ADR-1239 / EoS).** `bin/install.js` routes `claude` install/uninstall through the imperative adapter (`createImperativeAdapter`) instead of calling the engine directly, and its 13 hardcoded `runtime === 'claude'` / `runtime !== 'claude'` branches are folded into descriptor-driven `runtime.hostBehaviors` on `capabilities/claude/capability.json` (permission schema, `settings.local.json` scope routing, `.gsd-source` marker, effort frontmatter, canonical-workflow authorship, and more). Install/uninstall output is **byte-identical** for both the global skills layout and the local legacy layout (golden-parity asserted for both scopes); no other runtime changes. Removes the "add-a-host tax" of scattered string-equality checks for the tier-1 reference host. No user-facing change. (#2086)
|
||||
5
.changeset/2087-eos-opencode-imperative-adapter.md
Normal file
5
.changeset/2087-eos-opencode-imperative-adapter.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Changed
|
||||
pr: 2108
|
||||
---
|
||||
**OpenCode is now driven through the public Host-Integration Interface, with two capability upgrades (ADR-1239 / EoS).** OpenCode and its Kilo sibling previously installed via a bespoke `runtime === 'opencode'`/`isOpencode` branch in `bin/install.js`; its commands+skills+plugin install now runs through the imperative adapter → the engine's combined-family install path (`installRuntimeArtifacts`), and every hardcoded `runtime === 'opencode'` branch is folded into descriptor-driven `runtime.hostBehaviors`. Install/uninstall output is **byte-identical** (golden parity asserted for all 16 runtimes). Two Context7-verified upgrades land: (1) **background dispatch** — OpenCode shipped experimental background subagents in v1.15 and made them default-on in v1.17, so `dispatch.background`/`backgroundDispatch` flip to `true`; GSD no longer force-flattens OpenCode-hosted wave dispatch (`shouldFlattenDispatch` now returns `false`), letting agents run concurrently where the host supports it. (2) **expanded event surface** — the OpenCode plugin now subscribes to `permission.asked`, `permission.replied`, and `session.error` (added to `EXTENSION_EVENT_SURFACES.opencode`), wiring the declared surface for future permission/error-aware bindings. (#2087)
|
||||
5
.changeset/2088-eos-codex-declarative-adapter.md
Normal file
5
.changeset/2088-eos-codex-declarative-adapter.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Changed
|
||||
pr: 2110
|
||||
---
|
||||
**Codex is now driven through the public Host-Integration Interface, with three capability upgrades (ADR-1239 / EoS).** Codex previously installed via hardcoded `runtime === 'codex'`/`isCodex` projection in `bin/install.js`; its `config.toml` / agent-`.toml` / `hooks.json` install now runs through the declarative embedding adapter and descriptor-driven `runtime.hostBehaviors`, with **zero** positive `isCodex` gates and **zero** `runtime === 'codex'` branches remaining (source-guarded). Install/uninstall output stays byte-parity-gated (`tests/fixtures/golden-install-parity/codex.json`). Three Context7-verified upgrades land, each with a test driving the user-reachable surface: (1) **skill root** — GSD skills now install to Codex's canonical `$HOME/.agents/skills` (via a skills-kind `home` override) instead of the deprecated `$CODEX_HOME/skills` fallback, and pre-move installs are migrated (stale `~/.codex/skills/gsd-*` cleaned on both install and uninstall, user-owned content preserved); (2) **hook events** — GSD registers the six documented Codex lifecycle events it previously skipped (`PreToolUse`, `PermissionRequest`, `PreCompact`, `PostCompact`, `SubagentStop`, `UserPromptSubmit`, in addition to the existing `SessionStart`/`SubagentStart`/`Stop`/`PostToolUse`) in `hooks.json`, so `gsd-context-monitor` fires at the same points as in Claude Code, and the descriptor `extendedHookEvents` is reconciled from `[]` to the schema-valid wired subset; (3) **dispatch tuning** — `[agents] max_depth = 1` is written explicitly into the managed `config.toml` block to pin the negotiated `dispatch.maxDepth: 1` axis (`degradationFor` flattens GSD-hosted waves to single-level), and `validateCodexConfigSchema` now permits a known-scalar-only `[agents]` AgentsToml table (coexisting with the flattened `[agents.gsd-*]` role sub-tables) while still rejecting the `[[agents]]` and unknown-key break-forms from #2760. (#2088)
|
||||
5
.changeset/2089-eos-cursor-imperative-adapter.md
Normal file
5
.changeset/2089-eos-cursor-imperative-adapter.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Changed
|
||||
pr: 2120
|
||||
---
|
||||
**Cursor is now driven through the public Host-Integration Interface, with two capability upgrades (ADR-1239 / EoS).** Cursor previously installed via hardcoded `runtime === 'cursor'`/`isCursor` branches in `bin/install.js`; its install/uninstall now runs through the imperative adapter, and every hardcoded cursor branch is folded into descriptor-driven `runtime.hostBehaviors` (reapplyCommand, frontmatterDialect, hooksJsonSurface, skipSharedHooksInstall, reportCommandsDir, managedHookEvents). Install/uninstall output is **byte-identical** (golden parity asserted for all 16 runtimes). Two Context7-verified upgrades land: (1) **expanded hook-bus coverage** — GSD registers all 6 managed lifecycle events in Cursor's `hooks.json` (`preToolUse`, `stop`, `subagentStart`, `subagentStop` in addition to the original `sessionStart`/`postToolUse`), driven by a new descriptor-driven adapter module (`src/host-integration-adapters/imperative-hook-bus.cts`) that reads `hostBehaviors.managedHookEvents` instead of a hardcoded event pair; cite https://cursor.com/docs/hooks. (2) **named/background nested subagent dispatch** — Cursor's `dispatch.background`/`backgroundDispatch`/`nested` are all `true` with `maxDepth: 2`, so `shouldFlattenDispatch(cursor)` returns `false` and GSD's wave-based execution drives Cursor's native background + depth-2 nested subagent invocation instead of flattening to inline sequential calls; cite https://cursor.com/docs/subagents + https://cursor.com/docs/sdk/typescript. (#2089)
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -69,6 +69,7 @@ build/
|
||||
/tsconfig.build.tsbuildinfo
|
||||
/gsd-core/bin/lib/host-integration.cjs
|
||||
/gsd-core/bin/lib/host-integration-sdk.cjs
|
||||
/gsd-core/bin/lib/host-integration-adapters/imperative-hook-bus.cjs
|
||||
/gsd-core/bin/lib/handshake-serialized.cjs
|
||||
/gsd-core/bin/lib/install-effort-resolver.cjs
|
||||
/gsd-core/bin/lib/install-engine.cjs
|
||||
@@ -250,3 +251,7 @@ reports/mutation/
|
||||
# Local Crabbox machine configuration
|
||||
.crabbox.yaml
|
||||
.crabbox.local.yaml
|
||||
|
||||
# Memtrace local daemon/runtime state (per-machine; never committed)
|
||||
.memdb/
|
||||
.memtrace/
|
||||
|
||||
@@ -663,6 +663,26 @@ const GsdCorePlugin = async ({ directory } = {}) => {
|
||||
if (event.type === "session.idle") {
|
||||
return;
|
||||
}
|
||||
|
||||
// permission.asked / permission.replied — OpenCode permission lifecycle
|
||||
// (#2087, opencode.ai/docs/plugins). GSD gates tool INPUTS at
|
||||
// tool.execute.before (read-guard, injection-scanner); the permission
|
||||
// grant/deny decision itself carries no GSD workflow-phase contribution,
|
||||
// so these are recognized sentinels — wired so a future permission-aware
|
||||
// gate can attach without a plugin change (the engine owns phase
|
||||
// sequencing; this host bus is session/tool/permission-scoped, never
|
||||
// phase-scoped — ADR-1239 §OpenCode).
|
||||
if (event.type === "permission.asked" || event.type === "permission.replied") {
|
||||
return;
|
||||
}
|
||||
|
||||
// session.error — OpenCode session-error lifecycle point (#2087). No GSD
|
||||
// hook fires here today (loop state is already persisted to .planning/);
|
||||
// recognized so the declared extension-event surface is fully wired and a
|
||||
// future error-class hook can attach without a plugin change.
|
||||
if (event.type === "session.error") {
|
||||
return;
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
800
bin/install.js
800
bin/install.js
File diff suppressed because it is too large
Load Diff
@@ -79,6 +79,21 @@
|
||||
"stateIO": "filesystem",
|
||||
"transport": "mcp",
|
||||
"runtime": "node"
|
||||
},
|
||||
"hostBehaviors": {
|
||||
"attributionSource": "settings-json-commit",
|
||||
"authorsCanonicalWorkflow": true,
|
||||
"localInstallStyle": "legacy-flat",
|
||||
"permissionsSchema": "claude",
|
||||
"settingsFileByScope": {
|
||||
"local": "settings.local.json",
|
||||
"global": "settings.json"
|
||||
},
|
||||
"sourceMarkerFile": ".gsd-source",
|
||||
"agentFrontmatterExtensions": ["effort"],
|
||||
"ownsClaudePaths": true,
|
||||
"nativeModelAliases": true,
|
||||
"skillsGlobalOnboarding": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,8 @@
|
||||
"prefix": "gsd-",
|
||||
"nesting": "flat",
|
||||
"recursive": false,
|
||||
"converter": "convertClaudeCommandToCodexSkill"
|
||||
"converter": "convertClaudeCommandToCodexSkill",
|
||||
"home": ".agents"
|
||||
}
|
||||
],
|
||||
"local": [
|
||||
@@ -37,7 +38,8 @@
|
||||
"prefix": "gsd-",
|
||||
"nesting": "flat",
|
||||
"recursive": false,
|
||||
"converter": "convertClaudeCommandToCodexSkill"
|
||||
"converter": "convertClaudeCommandToCodexSkill",
|
||||
"home": ".agents"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -49,7 +51,11 @@
|
||||
"installSurface": "codex-toml",
|
||||
"writesSharedSettings": false,
|
||||
"permissionWriter": null,
|
||||
"extendedHookEvents": [],
|
||||
"extendedHookEvents": [
|
||||
"SubagentStop",
|
||||
"Stop",
|
||||
"PreCompact"
|
||||
],
|
||||
"hostIntegration": {
|
||||
"embeddingMode": "declarative",
|
||||
"commandSurface": "slash-file",
|
||||
@@ -66,6 +72,13 @@
|
||||
"stateIO": "filesystem",
|
||||
"transport": "mcp",
|
||||
"runtime": "node"
|
||||
},
|
||||
"hostBehaviors": {
|
||||
"reapplyCommand": "$gsd-update --reapply",
|
||||
"tomlConfigInstall": true,
|
||||
"cleanupSkillSidecars": true,
|
||||
"agentTomlFiles": true,
|
||||
"frontmatterDialect": "codex"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,9 @@
|
||||
"stateIO": "filesystem",
|
||||
"transport": "mcp",
|
||||
"runtime": "undocumented"
|
||||
},
|
||||
"hostBehaviors": {
|
||||
"reapplyCommand": "/gsd-update --reapply"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +98,23 @@
|
||||
"stateIO": "filesystem",
|
||||
"transport": "mcp",
|
||||
"runtime": "node"
|
||||
},
|
||||
"hostBehaviors": {
|
||||
"reapplyCommand": "gsd-update --reapply (mention the skill name)",
|
||||
"frontmatterDialect": "cursor",
|
||||
"hooksJsonSurface": true,
|
||||
"skipSharedHooksInstall": true,
|
||||
"reportCommandsDir": true,
|
||||
"skipUpdateBannerCommand": true,
|
||||
"skipSettingsUi": true,
|
||||
"managedHookEvents": [
|
||||
"sessionStart",
|
||||
"postToolUse",
|
||||
"preToolUse",
|
||||
"stop",
|
||||
"subagentStart",
|
||||
"subagentStop"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,14 @@
|
||||
"stateIO": "filesystem",
|
||||
"transport": "mcp",
|
||||
"runtime": "bun"
|
||||
},
|
||||
"hostBehaviors": {
|
||||
"reapplyCommand": "/gsd-update --reapply",
|
||||
"attributionConfigResolver": "kilo",
|
||||
"flatCommandDir": "command",
|
||||
"combinedFamilyInstall": true,
|
||||
"frontmatterDialect": "kilo",
|
||||
"skipUpdateBannerCommand": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,15 +75,31 @@
|
||||
"namedDispatch": true,
|
||||
"nested": "undocumented",
|
||||
"maxDepth": "undocumented",
|
||||
"background": false,
|
||||
"background": true,
|
||||
"subagentToolkit": "full",
|
||||
"backgroundDispatch": "undocumented"
|
||||
"backgroundDispatch": true
|
||||
},
|
||||
"modelMode": "active",
|
||||
"hookBus": "host",
|
||||
"stateIO": "filesystem",
|
||||
"transport": "mcp",
|
||||
"runtime": "bun"
|
||||
},
|
||||
"hostBehaviors": {
|
||||
"reapplyCommand": "/gsd-update --reapply",
|
||||
"attributionConfigResolver": "opencode",
|
||||
"flatCommandDir": "command",
|
||||
"combinedFamilyInstall": true,
|
||||
"frontmatterDialect": "opencode",
|
||||
"nativePlugin": {
|
||||
"dir": "plugins",
|
||||
"file": "gsd-core.js",
|
||||
"source": ".opencode/plugins/gsd-core.js"
|
||||
},
|
||||
"skipHomePrefixSubstitution": true,
|
||||
"skipSettingsUi": true,
|
||||
"skipUpdateBannerCommand": true,
|
||||
"skipCodexSkillsManifest": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -452,7 +452,11 @@
|
||||
"gsd-config-reload.js",
|
||||
"gsd-context-monitor.js",
|
||||
"gsd-cursor-post-tool.js",
|
||||
"gsd-cursor-pre-tool.js",
|
||||
"gsd-cursor-session-start.js",
|
||||
"gsd-cursor-stop.js",
|
||||
"gsd-cursor-subagent-start.js",
|
||||
"gsd-cursor-subagent-stop.js",
|
||||
"gsd-ensure-canonical-path.js",
|
||||
"gsd-graphify-update.sh",
|
||||
"gsd-phase-boundary.sh",
|
||||
|
||||
@@ -548,6 +548,10 @@ Full listing: `hooks/`.
|
||||
| `gsd-update-banner.js` | `SessionStart` | Opt-in banner surfacing update availability when GSD statusline isn't used (PR #2795) |
|
||||
| `gsd-cursor-session-start.js` | Cursor `sessionStart` | Cursor-native context injection at session start (issue #777) |
|
||||
| `gsd-cursor-post-tool.js` | Cursor `postToolUse` | Cursor-native STATE.md update monitor after tool calls (issue #777) |
|
||||
| `gsd-cursor-pre-tool.js` | Cursor `preToolUse` | Cursor-native write-path guard for `.planning/` (ADR-1239 / #2089) |
|
||||
| `gsd-cursor-stop.js` | Cursor `stop` | Cursor-native verify-work reminder on agent stop (ADR-1239 / #2089) |
|
||||
| `gsd-cursor-subagent-start.js` | Cursor `subagentStart` | Cursor-native subagent context injection (ADR-1239 / #2089) |
|
||||
| `gsd-cursor-subagent-stop.js` | Cursor `subagentStop` | Cursor-native subagent completion reminder (ADR-1239 / #2089) |
|
||||
| `gsd-prompt-guard.js` | `PreToolUse` | Scans `.planning/` writes for prompt-injection patterns (advisory) |
|
||||
| `gsd-workflow-guard.js` | `PreToolUse` | Detects file edits outside GSD workflow context (advisory, opt-in) |
|
||||
| `gsd-read-guard.js` | `PreToolUse` | Advisory guard preventing Edit/Write on unread files |
|
||||
|
||||
168
docs/adr/2121-phase-identifier-parsing-consolidation.md
Normal file
168
docs/adr/2121-phase-identifier-parsing-consolidation.md
Normal file
@@ -0,0 +1,168 @@
|
||||
# ADR-2121: Phase-Identifier Parsing Consolidation
|
||||
|
||||
- **Status:** Accepted (Phase 0 — ADR only; locks the contract Phases 1–4 execute against. No production code lands in this PR.)
|
||||
- **Date:** 2026-07-09
|
||||
- **Issue:** [#2121](https://github.com/open-gsd/gsd-core/issues/2121) — epic (tech-debt / root-cause consolidation, `type: chore` + `approved-enhancement`)
|
||||
- **Supersedes:** nothing
|
||||
- **Relationship to prior work:** completes [#1455](https://github.com/open-gsd/gsd-core/issues/1455) (which introduced the prefix-tolerant lookup source but only in `roadmap-parser.cts`); it is the parser-layer analog of the `package-identity.cjs` single-source seam (ADR-referenced by `scripts/lint-package-identity-drift.cjs`).
|
||||
|
||||
## Context
|
||||
|
||||
Phase-identifier parsing — turning a phase reference (`3`, `03`, `12A`, `2.7`, `2-01`, `CK-01`, `AB-29`, `Milestone v0.5 complete`) into a normalized identity, a ROADMAP heading match, or a resolved phase — is **implemented independently in at least six modules**. `src/phase-id.cts` exists and is *meant* to be the canonical normalizer, but the surrounding modules each roll their own regex instead of delegating. A fix or invariant lands on one surface and its siblings silently diverge, so the same defect keeps re-surfacing under new issue numbers.
|
||||
|
||||
This is exactly the class `CLAUDE.md` warns about under **Generative Fix Divergence**:
|
||||
|
||||
> When sharing constants/arrays/parsers between parallel surfaces, add a parity assertion test that fails if they diverge.
|
||||
|
||||
The guard rule exists in the standards, but it is not applied to phase-ID parsing. Three confirmed bugs are the direct consequence:
|
||||
|
||||
| Symptom issue | Site | Divergent behavior | Blocked? |
|
||||
|---|---|---|---|
|
||||
| [#2111](https://github.com/open-gsd/gsd-core/issues/2111) | `state.cts:parseProsePhaseField` (`state.cts:1118-1131`) | `/\b(\d+[A-Z]?(?:\.\d+)*)\b/i` mines the *first* numeral in a prose `Phase:` line; `Milestone v0.5 complete` → `5`, `v1.0` → `0` (a reserved sentinel). `milestone complete v0.5` writes `current_phase: 5` instead of the real last phase. | No |
|
||||
| [#2114](https://github.com/open-gsd/gsd-core/issues/2114) | `roadmap.cts:cmdRoadmapGetPhase` (`roadmap.cts:238-303`) + `getRoadmapPhaseWithFallback` (`roadmap.cts:209-234`) | Both hand-roll a **2-source** lookup (exact → numeric). `getRoadmapPhaseInternal` (`roadmap-parser.cts:262-287`) loops a **3-source** pass (exact → numeric → prefix-tolerant) via `roadmapPhaseLookupSources`. `roadmap get-phase 29` returns empty for `### Phase AB-29:` while `init.phase-op 29` resolves it. | No |
|
||||
| [#2104](https://github.com/open-gsd/gsd-core/issues/2104) | `phase-id.cts:normalizePhaseName` / `stripProjectCodePrefix` (`phase-id.cts:44-77`) | `PROJECT_CODE_PREFIX_STRIP_RE_I = /^[A-Z][A-Z0-9_]*-(?=\d)/i` strips *any* prefix-shaped token with no check against the configured `project_code`; `MEM-01` collapses to bare `01` even when the project code is `LKML`. The #2056 guard was added to `cmdInitPlanPhase` only; the three sibling init commands still collapse foreign prefixes. | **Yes — sequenced after PR #2105 (#2056)** |
|
||||
|
||||
### Why #1455 did not close the loop
|
||||
|
||||
`git show 2dedbdd11` (fix(#1455)) touched `phase-id.cts`, `phase.cts`, `roadmap-parser.cts`, `roadmap-upgrade.cts`, `validate.cts` — **not `roadmap.cts`**. It added `OPTIONAL_PROJECT_CODE_PREFIX_SOURCE` (`phase-id.cts:24`) and the third lookup source inside `roadmapPhaseLookupSources` (`roadmap-parser.cts:245-260`), but `roadmap.cts` never imported the constant. The mechanical root of #2114 is an import-list asymmetry: `roadmap.cts:16-17` destructures seven names from `phase-id.cjs` but omits `OPTIONAL_PROJECT_CODE_PREFIX_SOURCE`, so it *structurally cannot* build the prefix-tolerant source today. A point fix on `roadmap.cts` would leave the divergence itself — one seam missing, N call sites free to re-diverge — fully intact.
|
||||
|
||||
### The full divergent surface (broader than the three modules the issue names)
|
||||
|
||||
Memtrace blast-radius analysis (`get_impact normalizePhaseName` → **risk CRITICAL, 84 affected symbols, 20 direct callers across 19 files**) and a symbol sweep surfaced the complete surface. This matters because it bounds both the back-compat risk and the guard's scope:
|
||||
|
||||
- **`phase-id.cts` (the partial canonical seam, 272 lines, pure — "no Node built-ins").** Already owns: `escapeRegex` (`:15`), `OPTIONAL_PROJECT_CODE_PREFIX_SOURCE` (`:24`), `OPTIONAL_PHASE_TAG_SOURCE` (`:42`), `stripProjectCodePrefix` (`:44`), `normalizePhaseName` (`:54`), `getMilestoneFromPhaseId` (`:79`), `getPhaseDirFromPhaseId` (`:88`), `phaseMarkdownRegexSource` (`:107`), `phaseMarkdownRegexSourceExact` (`:138`), `comparePhaseNum` (`:144`), `extractPhaseToken` (`:197`), `phaseTokenMatches` (`:247`). It does **not** parse-from-prose, and its regex-source builders are consumed by callers, not applied here.
|
||||
- **`state.cts` — five independently-maintained phase-token regex shapes:** `parseProsePhaseField` (`:1120`, the `\b…\b` miner), its unanchored twins `resolvePhaseIdForCompletePhase` (`:2722`) and `cmdStateCompletePhase` (`:2750`), the `Phase`-anchored `extractRetiredPhaseNumbers` (`:1319`), the strip/pad idiom in `cmdStateValidate` (`:2291`,`:2294`), and the digits-only dir shape in `phaseInventoryProvider` (`:2642`). Only `phaseKeyFromToken`/`phaseKeyFromDir` (`:1283-1288`) delegate to `phase-id.cjs`.
|
||||
- **`roadmap.cts` — four regex-construction sites** (`:410`, `:528`, `:787`, plus the two named CLI functions). The three standalone sites already use the canonical `phaseMarkdownRegexSource` builder; only the two named functions diverge (no prefix-tolerant source).
|
||||
- **`roadmap-parser.cts`** — `roadmapPhaseLookupSources` (`:245`, the canonical 3-source ordering), `getRoadmapPhaseInternal` (`:262`, the one impure resolver), `findRoadmapPhaseInContent` (`:214`).
|
||||
- **`init.cts`** — on `next` today, init resolves a phase query through the config-blind `stripProjectCodePrefix` / `normalizePhaseName` path (`init.cts:76`, `:1184`), so its three sibling commands (`cmdInitExecutePhase`, `cmdInitVerifyWork`, `cmdInitPhaseOp`) collapse foreign-prefixed IDs (the #2104 symptom). The config-aware guard family (`parsePhasePrefix` / `isForeignPrefixedPhaseQuery` / `roadmapPhaseMatchesExactPrefix`, with `/^([A-Z][A-Z0-9_]*)-(?=\d)/i`) is being introduced by #2056 on the **unmerged PR #2105** (`fix/2056-plan-phase-foreign-prefix`) and is **not yet on `next`** — it is **Cluster 1 / #2104 domain**, and its line numbers are omitted here deliberately because they will drift when #2105 lands.
|
||||
- **`validate.cts`** — `buildNotStartedPhaseVariants` with its own `Phase\s+([\w][\w.-]*)` regex; `phaseVariants`.
|
||||
- **Two distinct ROADMAP content matchers:** `searchPhaseInContent` (`roadmap.cts:126`, uses `OPTIONAL_PHASE_TAG_SOURCE` + a checklist fallback + `tokenizeHeadings`) vs `findRoadmapPhaseInContent` (`roadmap-parser.cts:214`).
|
||||
- **`escapeRegex` is duplicated** in `phase-id.cts:15` and `state-document.cts:11`.
|
||||
|
||||
Per `CONTRIBUTING.md`:
|
||||
|
||||
> **One issue = one ADR-or-PRD = one PR.** Do not batch multiple decisions into one file or one PR.
|
||||
|
||||
This ADR is that one file. It decides and **locks** the target seam; it ships no production code. Phases 1–4 execute against it as separate PRs.
|
||||
|
||||
## Decision
|
||||
|
||||
Make `src/phase-id.cts` the **single canonical owner** of every phase-identifier operation, migrate the divergent consumers to delegate to it, and add a machine-enforced anti-divergence guard so no future module can re-implement phase-ID parsing without failing CI. Seven decisions, locked below.
|
||||
|
||||
### 1. `phase-id.cts` is the sole owner of phase-identifier parsing
|
||||
|
||||
"Phase-identifier parsing" is defined as the closed set of operations: **normalize**, **compare**, **project-code prefix policy**, **parse-from-prose**, **parse-from-heading (regex-source construction + lookup-source ordering)**, **parse-from-dir-name (token extraction + match predicate)**, and **parse-from-CLI-query**. Every function in that set lives in `phase-id.cts` (or, for the one operation that must touch the filesystem, in the single resolver named in Decision 5). Consumers `require`/`import` the canonical functions; **no consumer defines a phase-identifier regex locally.**
|
||||
|
||||
*Rejected:* (B) a new `phase-resolver.cts` module — rejected because `phase-id.cts` already holds twelve of these functions and 20 direct callers; a new module would create a *second* seam and worsen the divergence it aims to fix. (C) leave parsing distributed but add a lint that all sites match a golden regex — rejected because it enforces textual sameness, not single-ownership, and cannot cover the semantic divergences (`\b…\b` vs unanchored vs `Phase`-anchored are all "valid" regex).
|
||||
|
||||
### 2. Extend, never mutate — the backward-compatibility guarantee (Hyrum's Law)
|
||||
|
||||
`normalizePhaseName` has a **CRITICAL** blast radius (84 affected symbols, 20 direct callers, 19 files). Its observable behavior — zero-padding, unconditional prefix stripping, letter-case preservation (`#1962`), milestone-form decomposition — is depended upon everywhere. **Locked:** Phases 1–4 may only *add* exported functions to `phase-id.cts`; they may **not** change the observable behavior of any of the twelve existing exports. Any behavior change to an existing export (including "fixing" the config-blind strip in place) is out of scope for this epic and requires its own ADR. The #2104 fix is delivered as a **new, config-aware** function (Decision 4), leaving the existing config-blind path untouched for its 20 callers.
|
||||
|
||||
### 3. The locked canonical surface (the exports Phase 1 adds)
|
||||
|
||||
Phase 1 adds exactly these pure functions to `phase-id.cts`. Signatures and contracts are **locked**; Phases 2–4 consume them verbatim.
|
||||
|
||||
**`parsePhaseFromProse(value: string | null): { phase: string | null; name: string | null }`**
|
||||
The anchored replacement for `state.cts:parseProsePhaseField`. It extracts a phase identifier **only** from a genuine phase reference — the literal token `Phase <id>` (optionally `Phase <id>: <name>`, `Phase <id> — <name>`, or `Phase <id> of <M>`). Invariants this seam pins:
|
||||
- A milestone-completion string carries **no** phase: `parsePhaseFromProse('Milestone v0.5 complete')` → `{ phase: null, name: null }` (fixes #2111). Likewise `v1.0`, `v2.10`, and any `Milestone v…` form.
|
||||
- A real reference parses: `'Phase 3A — Delta (executing)'` → `{ phase: '3A', name: 'Delta' }`.
|
||||
- It never mines a stray numeral from surrounding prose; absence of a `Phase` anchor yields `{ phase: null }`, not a guessed number.
|
||||
|
||||
**`stripConfiguredProjectCodePrefix(value: unknown, projectCode: string | null | undefined): string`**
|
||||
The config-aware prefix stripper. Strips a leading `<CODE>-` **only** when `<CODE>` case-insensitively equals `projectCode`; a foreign prefix (`MEM-` when the code is `LKML`) or an absent/empty `projectCode` leaves the value **verbatim**. This is the canonical home for the #2104 fix. It *complements* — does not replace — the existing config-blind `stripProjectCodePrefix` (Decision 2).
|
||||
|
||||
**`isForeignPrefixedPhaseQuery(phase: unknown, projectCode: unknown): boolean`**
|
||||
The canonical predicate that #2056's guard family — arriving on the **unmerged PR #2105**, not yet on `next` — will delegate to once it lands: `true` when `phase` carries a prefix that is not the configured `projectCode`. Locking it here means #2105's `cmdInitPlanPhase` guard and the three #2104 sibling commands share **one** foreign-prefix rule instead of the divergent copies they would otherwise seed.
|
||||
|
||||
**`roadmapPhaseLookupSources(phaseNum: unknown): string[]`** *(moved from `roadmap-parser.cts:245-260`)*
|
||||
The canonical heading lookup-source builder becomes an owned export of `phase-id.cts` (it is already pure — it only composes regex-source strings). All three roadmap call sites consume it, so the ordering (Decision 5) has exactly one definition.
|
||||
|
||||
**Parse-from-CLI-query — no new function (locked).** A CLI-supplied phase argument (`gsd-tools … <phase>`) is resolved by *composing existing locked primitives*, not a new parser: `extractPhaseToken` / `normalizePhaseName` (token + normalize, Decision 2) → `isForeignPrefixedPhaseQuery` / `stripConfiguredProjectCodePrefix` (config-aware prefix policy, Decision 4) → `phaseTokenMatches` for dir-name resolution or `roadmapPhaseLookupSources` → `getRoadmapPhaseInternal` for heading resolution. This is deliberately *not* a distinct `parseCliQuery` function: callers already know they hold a CLI arg, and a discriminated god-parser would re-widen the accept surface (Postel's Law). The lock is that CLI-query resolution routes through these primitives only — no consumer re-derives a phase from a CLI arg with its own regex.
|
||||
|
||||
*Rejected:* (B) fixing `parseProsePhaseField` in place with a tighter regex but leaving it in `state.cts` — rejected because the fix would not be reusable by the other prose sites and would re-seed the divergence. (C) a single mega-parser `parsePhaseId(input, kind)` with a `kind` discriminator — rejected (Postel's Law / interface clarity): callers already know whether they hold prose, a heading, a dir name, or a CLI arg; a discriminated god-function hides that and widens the accept surface.
|
||||
|
||||
### 4. Project-code prefix policy — config-aware stripping is the resolution path
|
||||
|
||||
**Locked policy:** a project-code prefix is a *display* prefix. For **identity/normalization** where config is unavailable, the config-blind `stripProjectCodePrefix` remains (back-compat). For **resolution of a caller-supplied query** (init commands, roadmap lookup) the config-aware `stripConfiguredProjectCodePrefix` / `isForeignPrefixedPhaseQuery` are the path: a query whose prefix is *not* this project's code must not collapse to a bare number and match a foreign phase. This tightens an over-liberal accept surface (Postel's Law) without touching the 20 callers of the blind stripper.
|
||||
|
||||
### 5. Lookup-source ordering — the locked invariant
|
||||
|
||||
The canonical resolution tries sources in this exact, de-duplicated order (as `roadmapPhaseLookupSources` implements today at `roadmap-parser.cts:251-259`):
|
||||
|
||||
1. **Exact** — `phaseMarkdownRegexSourceExact(phaseNum)` — non-null only when the query itself carries a prefix; matches `### Phase PROJ-42:` verbatim.
|
||||
2. **Numeric / padding-tolerant** — `phaseMarkdownRegexSource(phaseNum)` — the canonical bare heading (`### Phase 42:`), padding-tolerant (`0*N`).
|
||||
3. **Prefix-tolerant** — `` `${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${numericSource}` `` — the drifted-only fallback (`### Phase MANIFOLD-117:` for a bare `117` query), de-duplicated via `[...new Set(sources)]`.
|
||||
|
||||
**Order matters and is locked:** bare-numeric is tried *before* prefix-tolerant so a canonical heading wins over a drifted one when both exist. The single impure ROADMAP resolver is **`getRoadmapPhaseInternal` (`roadmap-parser.cts:262`)** — it reads `ROADMAP.md` and loops these sources. `roadmap.cts`'s CLI siblings (`cmdRoadmapGetPhase`, `getRoadmapPhaseWithFallback`) **delegate to it** rather than re-scanning content, collapsing the `searchPhaseInContent` vs `findRoadmapPhaseInContent` duplication onto one resolution path — this is precisely the delegation #2114 requests.
|
||||
|
||||
### 6. Migration order & backward-compatibility guarantees
|
||||
|
||||
Each phase is its own small PR, opened under a fresh `chore(#2121): … — Phase N` sub-issue, and lands **in order** — Phase N+1 does not begin until Phase N merges. No phase changes any observable CLI output **except** the corrected resolution for the cited symptom cases.
|
||||
|
||||
| Phase | Scope | Drives green | Sub-issue |
|
||||
|---|---|---|---|
|
||||
| **0** | This ADR — lock the contract. No production code. | — | Closes #2121 |
|
||||
| **1** | Add the Decision-3 functions to `phase-id.cts`; move `roadmapPhaseLookupSources` in. Exhaustive unit tests + boundary cases (`v0.5`, `v1.0`, `MEM-01`, `AB-29`, bare `29`, zero-padded `029`) + ≥1 `fast-check` property test for the parse↔normalize contract. **No consumer changes.** | — | new |
|
||||
| **2** | Migrate `state.cts` prose/number sites (`parseProsePhaseField` → `parsePhaseFromProse`; the unanchored twins `resolvePhaseIdForCompletePhase`, `cmdStateCompletePhase`; align the dir/pad shapes on `extractPhaseToken`/`normalizePhaseName`). Regression-first: assert `current_phase` survives a `milestone complete v0.5` close unchanged. | **#2111** | new |
|
||||
| **3** | Delegate `roadmap.cts:cmdRoadmapGetPhase` + `getRoadmapPhaseWithFallback` to `getRoadmapPhaseInternal` / `roadmapPhaseLookupSources`. Regression-first: `roadmap get-phase <bare-N>` resolves `### Phase AB-N:`, and both CLI siblings route through the same lookup sources as the internal resolver. | **#2114** | new |
|
||||
| **4** | Add the Decision-7 anti-divergence guard; inventory-sweep the remaining sites. | closes the recurrence loop | new |
|
||||
|
||||
**#2104 disposition (locked):** Phase 1 builds the config-aware prefix API (Decision 4) so #2104's fix has a canonical home, but **#2104's own migration** (applying the guard to `cmdInitExecutePhase` / `cmdInitVerifyWork` / `cmdInitPhaseOp`) is **blocked on PR #2105 (#2056)** — the guard helpers it must reuse do not exist on `next` yet — and stays tracked on #2104, **outside this epic's critical path**. Phases 1–4 must not block on #2104, and #2104's init sites are allowlisted by the Phase-4 guard (Decision 7) until #2105 lands.
|
||||
|
||||
### 7. The anti-divergence contract (the parity guard)
|
||||
|
||||
**Locked mechanism**, modeled on the repo's two proven single-source patterns — `tests/capability-precedence-parity.test.cjs` (identity guard) and `scripts/lint-package-identity-drift.cjs` + `tests/issue-498-identity-drift-lint.test.cjs` (drift scanner):
|
||||
|
||||
1. **Identity guard test** — for every consumer that re-exports a canonical phase-ID function, assert reference identity: `assert.strictEqual(consumer.fn, phaseId.fn)`. A pasted re-implementation is a different function object and fails instantly (the mechanism `capability-precedence-parity.test.cjs:44-51` uses).
|
||||
2. **Drift scanner** — `scripts/lint-phase-id-drift.cjs` exports a **pure** `findPhaseIdRegexDrift(text, opts)` that flags phase-ID-shaped regex literals (`\d+[A-Z]?(?:\.\d+)*`, `[A-Z][A-Z0-9_]*-`, and `Phase\s+…:` heading builders) defined in any `src/*.cts` other than `phase-id.cts`. It is wired to `npm run check:phase-id-drift` and asserted zero via a `scanRepo(ROOT)` integration test. A narrow allowlist keyed by an explicit `// phase-id-owner: <reason>` comment covers sanctioned exceptions (e.g. Cluster-1 / #2104 init sites, `// phase-id-owner: cluster-1-#2104`) until they migrate.
|
||||
|
||||
**Locked constraints on the guard's own implementation** (so it does not become new tech debt):
|
||||
- It must be **behavioral**, not a `readFileSync(path).includes(...)` inside a `tests/**/*.test.cjs` file — that trips `eslint-rules/no-source-grep.cjs` (bound `local/no-source-grep`, `error` in tests). Text-scanning lives in the `scripts/` pure function; the test `require()`s it and calls it with **inline string literals**, per `tests/issue-498-identity-drift-lint.test.cjs:21-25`.
|
||||
- It must **not** be modeled on `tests/package-name-single-source.test.cjs`, which only *appears* to satisfy `no-source-grep` because the rule's taint-tracking loses the variable after `.split()` — an evasion, not an exemption.
|
||||
|
||||
*Rejected:* (B) an ESLint `no-restricted-syntax` rule — the repo has exactly one such rule (test-timing hygiene, `eslint.config.mjs:363`) and no single-ownership lint precedent; a `node:test` behavioral contract is the established, proven pattern. (C) outcome-parity only (run two paths, diff outputs, as `tests/phase.test.cjs:6881` `expectParity` does for #3537) — necessary but insufficient: it proves two paths *agree today*, not that only one *implementation* exists, so it cannot catch a third divergent site added tomorrow.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
- The recurring #2111/#2114-class bug is root-caused, not point-fixed: one seam owns the parsing, and the Phase-4 guard makes re-divergence a CI failure rather than a future issue number.
|
||||
- #2114's `roadmap get-phase` / `ui-plan-gate` split is closed by delegation, and the two ROADMAP content matchers collapse to one resolver.
|
||||
- The config-aware prefix API gives #2104 (and its siblings #1836, #2056) a single correct home the moment #2105 lands.
|
||||
- Callers gain named, tested parsing functions in place of inline `\b…\b` cleverness that is hard to read and harder to debug (Kernighan's Law).
|
||||
|
||||
**Negative:**
|
||||
- Touching `normalizePhaseName`'s neighborhood is high-risk (84 affected symbols). Decision 2 (extend-never-mutate) contains the risk but constrains the design — the fixes must be new functions, not tighter versions of the old ones.
|
||||
- Four sequential PRs plus sub-issues is more process overhead than a single "fix the three bugs" PR — accepted, because a batched fix would re-seed the very divergence this epic removes and violates one-issue-one-PR.
|
||||
- The Phase-4 guard adds an allowlist that must be curated as #2104/#2105 land; a stale allowlist entry is a small, visible debt rather than a silent gap.
|
||||
|
||||
**Neutral:**
|
||||
- `phase-id.cts` grows from a normalizer into the full phase-ID surface; it stays pure (no Node built-ins), so the FS-touching resolver deliberately remains `getRoadmapPhaseInternal` in `roadmap-parser.cts`.
|
||||
- `#2104` remains open and independently tracked; this epic neither closes nor blocks on it.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
1. **Point-fix each of the three bugs in place.** Rejected: leaves the seam absent, so surface #4 (init.cts, validate.cts) re-diverges under the next issue number — the exact history from #905 → #2111, #1455 → #2114, #2056 → #2104.
|
||||
2. **One big PR consolidating everything at once.** Rejected: violates `CONTRIBUTING.md` "One issue = one … = one PR"; unreviewable across a CRITICAL-blast-radius symbol; no fail-first regression discipline per bug.
|
||||
3. **Golden-regex lint (all sites must textually match one pattern).** Rejected: enforces textual sameness, not single ownership, and cannot express the semantic divergence (anchored vs unanchored vs `Phase`-anchored are all syntactically valid).
|
||||
4. **Second module (`phase-resolver.cts`).** Rejected: a new seam alongside the existing `phase-id.cts` seam deepens, rather than removes, the divergence.
|
||||
|
||||
## Software laws applied
|
||||
|
||||
Cross-referenced via `/skills-from-the-artificer`; the laws that materially shaped the decisions:
|
||||
|
||||
- **Hyrum's Law** — `normalizePhaseName`'s 20 callers depend on its observable behavior ⇒ *extend, never mutate* (Decision 2).
|
||||
- **Postel's Law** — the current parsers are too liberal (mine any numeral; strip any prefix) ⇒ tighten acceptance to the anchored / config-aware forms (Decisions 3–4).
|
||||
- **Gall's Law** — `phase-id.cts` is a working simple system; grow it incrementally through a phased epic rather than a big-bang rewrite (Decisions 1, 6).
|
||||
- **DRY / single-source-of-truth (Generative Fix Divergence)** — one seam, guarded, is the whole point (Decisions 1, 7).
|
||||
- **Kernighan's Law** — inline `\b…\b` one-liners are hard to debug; naming + centralizing them lowers the debugging cost the bugs were paying (Decision 3).
|
||||
|
||||
## Cross-references
|
||||
|
||||
- Symptom issues: [#2111](https://github.com/open-gsd/gsd-core/issues/2111), [#2114](https://github.com/open-gsd/gsd-core/issues/2114), [#2104](https://github.com/open-gsd/gsd-core/issues/2104) (blocked on [#2105](https://github.com/open-gsd/gsd-core/issues/2105)/#2056).
|
||||
- Prior art: #1455 (`OPTIONAL_PROJECT_CODE_PREFIX_SOURCE`, `roadmap-parser.cts`); `CLAUDE.md` → "Generative Fix Divergence"; `scripts/lint-package-identity-drift.cjs` + `tests/capability-precedence-parity.test.cjs` (the guard models).
|
||||
- Owner seam: `src/phase-id.cts`. Impure resolver: `src/roadmap-parser.cts:getRoadmapPhaseInternal`.
|
||||
|
||||
## Amendments
|
||||
|
||||
*(none yet — append-only; amendments extend this ADR with a dated `### #<issue> — <topic>` section rather than rewriting the body above.)*
|
||||
@@ -65,6 +65,7 @@ See **[CONTRIBUTING.md — "Proposing an ADR or PRD"](../../CONTRIBUTING.md#prop
|
||||
| [1817-state-md-rebuild-derivability-contract.md](1817-state-md-rebuild-derivability-contract.md) | STATE.md rebuild — derivability contract (capstone 11th transition) | Accepted |
|
||||
| [2008-command-exit-zero-gate.md](2008-command-exit-zero-gate.md) | Generic gate-predicate evaluator with a `command-exit-zero` kind (#2008) | Accepted |
|
||||
| [1990-existing-code-onboarding.md](1990-existing-code-onboarding.md) | Existing Code Onboarding Module owns deterministic repo-state detection and onboarding route selection | Proposed |
|
||||
| [2121-phase-identifier-parsing-consolidation.md](2121-phase-identifier-parsing-consolidation.md) | Phase-identifier parsing consolidation — single canonical owner (phase-id.cts) + anti-divergence guard | Accepted |
|
||||
|
||||
## Seam map
|
||||
|
||||
|
||||
@@ -102,6 +102,51 @@ a first-party primitive, reviewed. To add one (e.g. a new `runtime` kind):
|
||||
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.
|
||||
|
||||
## 7. Fold an already-hardcoded host into the interface (worked example: `claude`)
|
||||
|
||||
Sections 1–6 cover a *green-field* host (`pi`, `antigravity` — a fresh descriptor + reference
|
||||
binding). This section covers the other case: a host that already has a **real production install**
|
||||
driven by scattered `runtime === '<id>'` string-equality branches in `bin/install.js`, which you want
|
||||
to move onto the Host-Integration Interface **without changing a single installed byte**. `claude`
|
||||
(the tier-1 reference host, #2086) is the worked example.
|
||||
|
||||
The pattern is byte-parity-safe by construction — each string check becomes a **descriptor lookup that
|
||||
yields the same truth value**, so behavior is unchanged and only the brittle coupling is removed:
|
||||
|
||||
1. **Inventory the branches.** Find every `runtime === '<id>'` / `runtime !== '<id>'` in `bin/install.js`
|
||||
for the host (`grep -nE "runtime\s*[!=]==\s*'claude'"`). Each is a host behavior encoded as a string
|
||||
comparison rather than a declared capability.
|
||||
|
||||
2. **Declare the behaviors on the descriptor.** Add a `runtime.hostBehaviors` object to the host's
|
||||
`capability.json`. Each key names one behavior the branches gated on — e.g. for `claude`:
|
||||
`permissionsSchema: "claude"`, `settingsFileByScope: { local: "settings.local.json", global: "settings.json" }`,
|
||||
`sourceMarkerFile: ".gsd-source"`, `agentFrontmatterExtensions: ["effort"]`, `localInstallStyle: "legacy-flat"`,
|
||||
`authorsCanonicalWorkflow: true`, `ownsClaudePaths: true`, `nativeModelAliases: true`,
|
||||
`skillsGlobalOnboarding: true`, `attributionSource: "settings-json-commit"`. The validator
|
||||
(`validateRuntimeBody`) is lenient toward these host-behavior keys; they carry install policy, not the
|
||||
closed negotiated axes.
|
||||
|
||||
3. **Replace each branch with a descriptor read.** `bin/install.js` exposes a `_hostBehaviors(runtime)`
|
||||
helper (reads `_capabilityRegistry.runtimes[runtime].runtime.hostBehaviors`, `{}` if absent). Rewrite
|
||||
`if (runtime === 'claude')` → `if (_hostBehaviors(runtime).permissionsSchema === 'claude')`, and
|
||||
`if (runtime !== 'claude')` → `if (!_hostBehaviors(runtime).authorsCanonicalWorkflow)`. Only the host
|
||||
declares the key, so every other runtime keeps the generic path.
|
||||
|
||||
4. **Route install/uninstall through the public adapter.** Replace the direct
|
||||
`installRuntimeArtifacts(...)` / `uninstallRuntimeArtifacts(...)` calls with
|
||||
`createImperativeAdapter({ runtime }).install({...})` / `.uninstall({...})`. The imperative adapter
|
||||
delegates to the *same* engine functions, so the output is byte-identical — that is the point: the
|
||||
host is now driven **through** the interface, not around it.
|
||||
|
||||
5. **Prove parity, both scopes.** `tests/golden-install-parity.test.cjs` captures a byte-stable manifest
|
||||
of every emitted file. Assert the host's install is unchanged for **global and local** scopes
|
||||
(regenerate a baseline from `origin/next` first, then confirm the migrated tree matches it). Exclude
|
||||
only genuinely volatile / platform-varying files (`settings.json`, `settings.local.json`, `.gsd-source`).
|
||||
|
||||
6. **Guard against regression.** Add a `*-imperative-reference.test.cjs` asserting the adapter classifies
|
||||
the host correctly, negotiation fails closed on a corrupted descriptor, and — with a source-grep behind
|
||||
an `// allow-test-rule:` exemption — that **no `runtime === '<id>'` branch remains** in `bin/install.js`.
|
||||
|
||||
---
|
||||
|
||||
## Related
|
||||
|
||||
@@ -101,6 +101,12 @@ Sources consulted:
|
||||
| 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 |
|
||||
|
||||
**GSD integration status — Phase D dogfood complete (#2088, ADR-1239).** Codex installs through the `declarative` embedding adapter (`createDeclarativeAdapter` → `installRuntimeArtifacts`); the hardcoded `runtime === 'codex'`/`isCodex` projection is folded into descriptor-driven `runtime.hostBehaviors`, and install/uninstall output is byte-parity-gated (`tests/fixtures/golden-install-parity/codex.json`). Three capability upgrades land, each with a test driving the user-reachable surface:
|
||||
|
||||
- **Skill root** — skills install to the canonical `$HOME/.agents/skills` (Codex core-skills `loader.rs` user-scope root), not the deprecated `$CODEX_HOME/skills` fallback. Declared via the skills-kind `home: ".agents"` override; pre-move installs are migrated (stale `~/.codex/skills/gsd-*` cleaned on both install and uninstall).
|
||||
- **Hook events** — GSD registers all documented `hooks.json` lifecycle events beyond `SessionStart`: `SubagentStart`, `Stop`, `PostToolUse` (#772), plus the six added in #2088 — `PreToolUse`, `PermissionRequest`, `PreCompact`, `PostCompact`, `SubagentStop`, `UserPromptSubmit` — all routed through `gsd-context-monitor.js`. (The descriptor `extendedHookEvents` field reflects the schema-valid cross-runtime subset `SubagentStop`/`Stop`/`PreCompact`; Codex's full event set is codex-hooks-json-native, registered directly in `hooks.json`.)
|
||||
- **Dispatch tuning** — `[agents] max_depth = 1` is written explicitly into the managed `config.toml` block, pinning the `dispatch.maxDepth: 1` axis instead of relying on codex-cli's implicit default. Because `maxDepth === 1`, `degradationFor` flattens GSD-hosted wave dispatch to single-level even though `dispatch.nested`/`background`/`backgroundDispatch` are all `true`. The block is a bare `[agents]` AgentsToml scalar table (coexisting with the flattened `[agents.gsd-*]` role sub-tables); `validateCodexConfigSchema` permits a known-scalar-only `[agents]` while still rejecting `[[agents]]` and unknown-key forms.
|
||||
|
||||
Sources consulted:
|
||||
- https://github.com/openai/codex (repo via gh CLI)
|
||||
- /openai/codex (Context7 library ID)
|
||||
@@ -134,9 +140,9 @@ Documentation gaps:
|
||||
| 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.background | true | https://github.com/anomalyco/opencode/blob/dev/packages/opencode/src/tool/task.ts (v1.15.0, commit 22de34c4d) + src/effect/runtime-flags.ts (v1.17, commit 81f6e0668) | "New in v1.15.0: experimental background subagents — the Task tool gains a `background` parameter (`Schema.optional(Schema.Boolean)`) that launches subagents asynchronously with completion notifications. v1.17: `BACKGROUND_SUBAGENTS_ENABLED = true` (\"feat: enable background subagents by default\") — default-on, concurrent execution in all modes. (#2087, superseding the stale sst/opencode#5887 snapshot)" |
|
||||
| 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. |
|
||||
| dispatch.backgroundDispatch | true | https://github.com/anomalyco/opencode/blob/dev/packages/opencode/src/effect/runtime-flags.ts (v1.17, commit 81f6e0668) + src/server/routes/instance/httpapi/handlers/experimental.ts | "v1.17 `BACKGROUND_SUBAGENTS_ENABLED = true` enables background subagent execution by default in all modes; the experimental capabilities endpoint exposes `{ backgroundSubagents: true }`. Background-spawned subagents run concurrently without blocking the main interaction flow. (#2087)" |
|
||||
|
||||
Sources consulted:
|
||||
- https://opencode.ai/docs/plugins
|
||||
@@ -181,6 +187,11 @@ Sources consulted:
|
||||
- https://cursor.com/docs/enterprise/llm-safety-and-controls
|
||||
- /websites/cursor (Context7)
|
||||
|
||||
**GSD integration status — Phase D dogfood complete (#2089, ADR-1239).** Cursor installs through the `imperative` embedding adapter (`createImperativeAdapter` → `installRuntimeArtifacts`); the hardcoded `runtime === 'cursor'` / `isCursor` projection is folded into descriptor-driven `runtime.hostBehaviors`, and install/uninstall output is byte-parity-gated (`tests/fixtures/golden-install-parity/cursor.json`). Two capability upgrades land, each with a test driving the user-reachable surface:
|
||||
|
||||
- **Expanded hook-bus coverage** — GSD registers all 6 managed lifecycle events in `hooks.json` beyond the original `sessionStart`/`postToolUse`: `preToolUse`, `stop`, `subagentStart`, `subagentStop` (AC4a, cite https://cursor.com/docs/hooks). The hook-bus binding is descriptor-driven via `src/host-integration-adapters/imperative-hook-bus.cts` (reads `hostBehaviors.managedHookEvents`), not a hardcoded event pair.
|
||||
- **Named/background nested subagent dispatch** — `dispatch.background`/`backgroundDispatch`/`nested` are all `true` with `maxDepth: 2`; `shouldFlattenDispatch(cursor)` returns `false` so GSD's wave-based execution drives Cursor's native background + depth-2 nested subagent dispatch instead of flattening to inline sequential calls (AC4b, cite https://cursor.com/docs/subagents + https://cursor.com/docs/sdk/typescript).
|
||||
|
||||
---
|
||||
|
||||
## cline
|
||||
|
||||
@@ -49,6 +49,9 @@ const PATH_RETURNING_FNS = [
|
||||
'resolveAgentDir',
|
||||
'getGlobalConfigDir',
|
||||
'getGlobalSkillsBase',
|
||||
// #2088 (ADR-1239 upgrade 3): resolves the on-disk skills-install dir honoring
|
||||
// a skills-kind `home` override (e.g. Codex → $HOME/.agents/skills).
|
||||
'_resolveSkillsRootDir',
|
||||
'getGlobalSkillDir',
|
||||
'getGlobalSkillDisplayPath',
|
||||
'resolveSkillsBaseFromDescriptor',
|
||||
|
||||
@@ -453,6 +453,23 @@ const capabilities = {
|
||||
"stateIO": "filesystem",
|
||||
"transport": "mcp",
|
||||
"runtime": "node"
|
||||
},
|
||||
"hostBehaviors": {
|
||||
"attributionSource": "settings-json-commit",
|
||||
"authorsCanonicalWorkflow": true,
|
||||
"localInstallStyle": "legacy-flat",
|
||||
"permissionsSchema": "claude",
|
||||
"settingsFileByScope": {
|
||||
"local": "settings.local.json",
|
||||
"global": "settings.json"
|
||||
},
|
||||
"sourceMarkerFile": ".gsd-source",
|
||||
"agentFrontmatterExtensions": [
|
||||
"effort"
|
||||
],
|
||||
"ownsClaudePaths": true,
|
||||
"nativeModelAliases": true,
|
||||
"skillsGlobalOnboarding": true
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -797,7 +814,8 @@ const capabilities = {
|
||||
"prefix": "gsd-",
|
||||
"nesting": "flat",
|
||||
"recursive": false,
|
||||
"converter": "convertClaudeCommandToCodexSkill"
|
||||
"converter": "convertClaudeCommandToCodexSkill",
|
||||
"home": ".agents"
|
||||
}
|
||||
],
|
||||
"local": [
|
||||
@@ -807,7 +825,8 @@ const capabilities = {
|
||||
"prefix": "gsd-",
|
||||
"nesting": "flat",
|
||||
"recursive": false,
|
||||
"converter": "convertClaudeCommandToCodexSkill"
|
||||
"converter": "convertClaudeCommandToCodexSkill",
|
||||
"home": ".agents"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -819,7 +838,11 @@ const capabilities = {
|
||||
"installSurface": "codex-toml",
|
||||
"writesSharedSettings": false,
|
||||
"permissionWriter": null,
|
||||
"extendedHookEvents": [],
|
||||
"extendedHookEvents": [
|
||||
"SubagentStop",
|
||||
"Stop",
|
||||
"PreCompact"
|
||||
],
|
||||
"hostIntegration": {
|
||||
"embeddingMode": "declarative",
|
||||
"commandSurface": "slash-file",
|
||||
@@ -836,6 +859,13 @@ const capabilities = {
|
||||
"stateIO": "filesystem",
|
||||
"transport": "mcp",
|
||||
"runtime": "node"
|
||||
},
|
||||
"hostBehaviors": {
|
||||
"reapplyCommand": "$gsd-update --reapply",
|
||||
"tomlConfigInstall": true,
|
||||
"cleanupSkillSidecars": true,
|
||||
"agentTomlFiles": true,
|
||||
"frontmatterDialect": "codex"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -923,6 +953,9 @@ const capabilities = {
|
||||
"stateIO": "filesystem",
|
||||
"transport": "mcp",
|
||||
"runtime": "undocumented"
|
||||
},
|
||||
"hostBehaviors": {
|
||||
"reapplyCommand": "/gsd-update --reapply"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1026,6 +1059,23 @@ const capabilities = {
|
||||
"stateIO": "filesystem",
|
||||
"transport": "mcp",
|
||||
"runtime": "node"
|
||||
},
|
||||
"hostBehaviors": {
|
||||
"reapplyCommand": "gsd-update --reapply (mention the skill name)",
|
||||
"frontmatterDialect": "cursor",
|
||||
"hooksJsonSurface": true,
|
||||
"skipSharedHooksInstall": true,
|
||||
"reportCommandsDir": true,
|
||||
"skipUpdateBannerCommand": true,
|
||||
"skipSettingsUi": true,
|
||||
"managedHookEvents": [
|
||||
"sessionStart",
|
||||
"postToolUse",
|
||||
"preToolUse",
|
||||
"stop",
|
||||
"subagentStart",
|
||||
"subagentStop"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1485,6 +1535,14 @@ const capabilities = {
|
||||
"stateIO": "filesystem",
|
||||
"transport": "mcp",
|
||||
"runtime": "bun"
|
||||
},
|
||||
"hostBehaviors": {
|
||||
"reapplyCommand": "/gsd-update --reapply",
|
||||
"attributionConfigResolver": "kilo",
|
||||
"flatCommandDir": "command",
|
||||
"combinedFamilyInstall": true,
|
||||
"frontmatterDialect": "kilo",
|
||||
"skipUpdateBannerCommand": true
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1863,15 +1921,31 @@ const capabilities = {
|
||||
"namedDispatch": true,
|
||||
"nested": "undocumented",
|
||||
"maxDepth": "undocumented",
|
||||
"background": false,
|
||||
"background": true,
|
||||
"subagentToolkit": "full",
|
||||
"backgroundDispatch": "undocumented"
|
||||
"backgroundDispatch": true
|
||||
},
|
||||
"modelMode": "active",
|
||||
"hookBus": "host",
|
||||
"stateIO": "filesystem",
|
||||
"transport": "mcp",
|
||||
"runtime": "bun"
|
||||
},
|
||||
"hostBehaviors": {
|
||||
"reapplyCommand": "/gsd-update --reapply",
|
||||
"attributionConfigResolver": "opencode",
|
||||
"flatCommandDir": "command",
|
||||
"combinedFamilyInstall": true,
|
||||
"frontmatterDialect": "opencode",
|
||||
"nativePlugin": {
|
||||
"dir": "plugins",
|
||||
"file": "gsd-core.js",
|
||||
"source": ".opencode/plugins/gsd-core.js"
|
||||
},
|
||||
"skipHomePrefixSubstitution": true,
|
||||
"skipSettingsUi": true,
|
||||
"skipUpdateBannerCommand": true,
|
||||
"skipCodexSkillsManifest": true
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -3877,6 +3951,23 @@ const runtimes = {
|
||||
"stateIO": "filesystem",
|
||||
"transport": "mcp",
|
||||
"runtime": "node"
|
||||
},
|
||||
"hostBehaviors": {
|
||||
"attributionSource": "settings-json-commit",
|
||||
"authorsCanonicalWorkflow": true,
|
||||
"localInstallStyle": "legacy-flat",
|
||||
"permissionsSchema": "claude",
|
||||
"settingsFileByScope": {
|
||||
"local": "settings.local.json",
|
||||
"global": "settings.json"
|
||||
},
|
||||
"sourceMarkerFile": ".gsd-source",
|
||||
"agentFrontmatterExtensions": [
|
||||
"effort"
|
||||
],
|
||||
"ownsClaudePaths": true,
|
||||
"nativeModelAliases": true,
|
||||
"skillsGlobalOnboarding": true
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -4073,7 +4164,8 @@ const runtimes = {
|
||||
"prefix": "gsd-",
|
||||
"nesting": "flat",
|
||||
"recursive": false,
|
||||
"converter": "convertClaudeCommandToCodexSkill"
|
||||
"converter": "convertClaudeCommandToCodexSkill",
|
||||
"home": ".agents"
|
||||
}
|
||||
],
|
||||
"local": [
|
||||
@@ -4083,7 +4175,8 @@ const runtimes = {
|
||||
"prefix": "gsd-",
|
||||
"nesting": "flat",
|
||||
"recursive": false,
|
||||
"converter": "convertClaudeCommandToCodexSkill"
|
||||
"converter": "convertClaudeCommandToCodexSkill",
|
||||
"home": ".agents"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -4095,7 +4188,11 @@ const runtimes = {
|
||||
"installSurface": "codex-toml",
|
||||
"writesSharedSettings": false,
|
||||
"permissionWriter": null,
|
||||
"extendedHookEvents": [],
|
||||
"extendedHookEvents": [
|
||||
"SubagentStop",
|
||||
"Stop",
|
||||
"PreCompact"
|
||||
],
|
||||
"hostIntegration": {
|
||||
"embeddingMode": "declarative",
|
||||
"commandSurface": "slash-file",
|
||||
@@ -4112,6 +4209,13 @@ const runtimes = {
|
||||
"stateIO": "filesystem",
|
||||
"transport": "mcp",
|
||||
"runtime": "node"
|
||||
},
|
||||
"hostBehaviors": {
|
||||
"reapplyCommand": "$gsd-update --reapply",
|
||||
"tomlConfigInstall": true,
|
||||
"cleanupSkillSidecars": true,
|
||||
"agentTomlFiles": true,
|
||||
"frontmatterDialect": "codex"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -4199,6 +4303,9 @@ const runtimes = {
|
||||
"stateIO": "filesystem",
|
||||
"transport": "mcp",
|
||||
"runtime": "undocumented"
|
||||
},
|
||||
"hostBehaviors": {
|
||||
"reapplyCommand": "/gsd-update --reapply"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -4302,6 +4409,23 @@ const runtimes = {
|
||||
"stateIO": "filesystem",
|
||||
"transport": "mcp",
|
||||
"runtime": "node"
|
||||
},
|
||||
"hostBehaviors": {
|
||||
"reapplyCommand": "gsd-update --reapply (mention the skill name)",
|
||||
"frontmatterDialect": "cursor",
|
||||
"hooksJsonSurface": true,
|
||||
"skipSharedHooksInstall": true,
|
||||
"reportCommandsDir": true,
|
||||
"skipUpdateBannerCommand": true,
|
||||
"skipSettingsUi": true,
|
||||
"managedHookEvents": [
|
||||
"sessionStart",
|
||||
"postToolUse",
|
||||
"preToolUse",
|
||||
"stop",
|
||||
"subagentStart",
|
||||
"subagentStop"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -4466,6 +4590,14 @@ const runtimes = {
|
||||
"stateIO": "filesystem",
|
||||
"transport": "mcp",
|
||||
"runtime": "bun"
|
||||
},
|
||||
"hostBehaviors": {
|
||||
"reapplyCommand": "/gsd-update --reapply",
|
||||
"attributionConfigResolver": "kilo",
|
||||
"flatCommandDir": "command",
|
||||
"combinedFamilyInstall": true,
|
||||
"frontmatterDialect": "kilo",
|
||||
"skipUpdateBannerCommand": true
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -4620,15 +4752,31 @@ const runtimes = {
|
||||
"namedDispatch": true,
|
||||
"nested": "undocumented",
|
||||
"maxDepth": "undocumented",
|
||||
"background": false,
|
||||
"background": true,
|
||||
"subagentToolkit": "full",
|
||||
"backgroundDispatch": "undocumented"
|
||||
"backgroundDispatch": true
|
||||
},
|
||||
"modelMode": "active",
|
||||
"hookBus": "host",
|
||||
"stateIO": "filesystem",
|
||||
"transport": "mcp",
|
||||
"runtime": "bun"
|
||||
},
|
||||
"hostBehaviors": {
|
||||
"reapplyCommand": "/gsd-update --reapply",
|
||||
"attributionConfigResolver": "opencode",
|
||||
"flatCommandDir": "command",
|
||||
"combinedFamilyInstall": true,
|
||||
"frontmatterDialect": "opencode",
|
||||
"nativePlugin": {
|
||||
"dir": "plugins",
|
||||
"file": "gsd-core.js",
|
||||
"source": ".opencode/plugins/gsd-core.js"
|
||||
},
|
||||
"skipHomePrefixSubstitution": true,
|
||||
"skipSettingsUi": true,
|
||||
"skipUpdateBannerCommand": true,
|
||||
"skipCodexSkillsManifest": true
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -111,7 +111,7 @@ function createRuntimeArtifactInstallPlan(args) {
|
||||
items.push({
|
||||
kind: kind.kind,
|
||||
sourceDir,
|
||||
destDir: assertDestWithinConfigHome(layout.configDir, kind.destSubpath),
|
||||
destDir: assertDestWithinConfigHome(kind.home ?? layout.configDir, kind.destSubpath),
|
||||
});
|
||||
}
|
||||
return { ok: true, plan: { items, cleanupDirs } };
|
||||
@@ -120,7 +120,7 @@ function createRuntimeArtifactUninstallPlan(layout) {
|
||||
return {
|
||||
items: layout.kinds.map((kind) => ({
|
||||
kind: kind.kind,
|
||||
destDir: assertDestWithinConfigHome(layout.configDir, kind.destSubpath),
|
||||
destDir: assertDestWithinConfigHome(kind.home ?? layout.configDir, kind.destSubpath),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -234,7 +234,8 @@ GEMINI_MODEL=$(gsd_run query config-get review.models.gemini 2>/dev/null | jq -r
|
||||
CLAUDE_MODEL=$(gsd_run query config-get review.models.claude 2>/dev/null | jq -r '.' 2>/dev/null || true)
|
||||
CODEX_MODEL=$(gsd_run query config-get review.models.codex 2>/dev/null | jq -r '.' 2>/dev/null || true)
|
||||
OPENCODE_MODEL=$(gsd_run query config-get review.models.opencode 2>/dev/null | jq -r '.' 2>/dev/null || true)
|
||||
# review.models.agy is reserved for future model-pinning support; agy selects its model internally
|
||||
# review.models.agy, when set, is passed to agy as --model (escape hatch for a
|
||||
# pinned model that 404s server-side); otherwise agy uses its persisted default.
|
||||
AGY_MODEL=$(gsd_run query config-get review.models.agy 2>/dev/null | jq -r '.' 2>/dev/null || true)
|
||||
|
||||
# #1115: `--dangerously-bypass-hook-trust` only exists on codex-cli >= 0.137.0.
|
||||
@@ -373,7 +374,7 @@ fi
|
||||
|
||||
**Antigravity CLI:**
|
||||
|
||||
**Maintainer note — why this block has three layers (last updated against agy 1.0.2):**
|
||||
**Maintainer note — why this block has three layers (last updated against agy 1.0.16):**
|
||||
|
||||
`agy -p` (the `--print` non-interactive flag) works correctly on macOS and Linux: it sends the
|
||||
prompt, receives the model response, and writes it to stdout. On **native Windows** it silently
|
||||
@@ -408,7 +409,9 @@ and Step 3 fires with a clear error message in REVIEWS.md. No silent corruption.
|
||||
Invocation specifics (verified agy 1.0.0, macOS arm64 and Linux amd64):
|
||||
- `-p` takes the prompt as a **flag value** — `echo X | agy -p` errors with "flag needs an argument: -p"
|
||||
- `--print-timeout` defaults to 5m, aligning with this workflow's global timeout
|
||||
- No `-m` / `--model` flag — agy selects the model internally
|
||||
- `--model "<name>"` selects the model (available since agy ~1.0.3; `agy models` lists
|
||||
them). When `review.models.agy` is set it is passed as `--model`; otherwise agy uses
|
||||
its persisted default (`agy models`).
|
||||
|
||||
```bash
|
||||
# Pre-flight: snapshot the transcript watermark before invoking agy.
|
||||
@@ -432,14 +435,41 @@ if [ -f "$_AGY_CACHE" ]; then
|
||||
fi
|
||||
|
||||
# Step 1 — primary invocation: stdout works on macOS, Linux, and WSL.
|
||||
# Bound the run with agy's OWN `--print-timeout` (issue #687). On a large,
|
||||
# file-path-rich prompt agy's agentic Cascade can loop on its code_search/grep
|
||||
# steps and never converge; `--print-timeout` is agy's native cap for print mode
|
||||
# (defaults to 5m — see maintainer note above), so we pass it explicitly to let a
|
||||
# stalled run self-terminate through the tool's own mechanism. A non-zero exit
|
||||
# (timeout or crash) discards any partial output so the Step 2 transcript fallback
|
||||
# / Step 3 stub take over.
|
||||
agy --print-timeout 300s -p "$(cat /tmp/gsd-review-prompt-{phase}.md)" 2>/dev/null > /tmp/gsd-review-antigravity-{phase}.md
|
||||
# Three hardening invariants (#2073), all mirroring the Cursor block's discipline:
|
||||
# * FILE-REFERENCE prompt (not inline `$(cat …)`) — a large review prompt (≈197 KB
|
||||
# for 6 plans + CONTEXT + RESEARCH + REQUIREMENTS) overflows the exec arg list
|
||||
# (`bash: agy: Argument list too long`, rc 126), indistinguishable from a model
|
||||
# failure when stderr is suppressed.
|
||||
# * EXTERNAL `timeout` wrapper when available (GNU `timeout` / `gtimeout`) —
|
||||
# `--print-timeout` is agy's native cap but it CANNOT fire before agy creates a
|
||||
# session; under concurrent heavy runs one process can stall pre-session (no
|
||||
# `brain/<conv-id>/` dir, alive at 583 s despite `--print-timeout 300s`). The
|
||||
# external cap bounds wall-clock regardless. Stock macOS lacks `timeout`, so
|
||||
# the block probes for it and falls back to --print-timeout alone there.
|
||||
# * `--model` from `review.models.agy` when set — escape hatch for a pinned model
|
||||
# that 404s server-side (exits 0 with empty stdout AND empty transcript).
|
||||
# * stdin tied to /dev/null so agy never blocks on a tty.
|
||||
# A non-zero exit (external timeout = 124, crash, etc.) discards any partial output
|
||||
# so the Step 2 transcript fallback / Step 3 diagnostic take over.
|
||||
if [ -n "$AGY_MODEL" ] && [ "$AGY_MODEL" != "null" ]; then
|
||||
set -- --model "$AGY_MODEL"
|
||||
else
|
||||
set --
|
||||
fi
|
||||
_AGY_PROMPT="Read the file at /tmp/gsd-review-prompt-{phase}.md in full and carry out the review request it contains. Output only the resulting markdown review. Do not edit any files."
|
||||
# Capability-probe an external wall-clock killer (GNU coreutils `timeout` or the
|
||||
# macOS Homebrew `gtimeout`). Stock macOS ships NEITHER — a bare `timeout …` would
|
||||
# fail with rc 127 ("command not found") and silently lose the reviewer, so fall
|
||||
# back to agy's native --print-timeout alone in that case. The external cap, when
|
||||
# available, is set HIGHER than --print-timeout so it only backstops a pre-session
|
||||
# stall (which --print-timeout cannot bound — #2073 mode 3) and never pre-empts a
|
||||
# healthy run. Mirrors the probe in scripts/base64-scan.sh.
|
||||
_AGY_KILLER="$(command -v timeout 2>/dev/null || command -v gtimeout 2>/dev/null || true)"
|
||||
if [ -n "$_AGY_KILLER" ]; then
|
||||
"$_AGY_KILLER" 600 agy --print-timeout 540s "$@" -p "$_AGY_PROMPT" </dev/null 2>/dev/null > /tmp/gsd-review-antigravity-{phase}.md
|
||||
else
|
||||
agy --print-timeout 540s "$@" -p "$_AGY_PROMPT" </dev/null 2>/dev/null > /tmp/gsd-review-antigravity-{phase}.md
|
||||
fi
|
||||
_AGY_RC=$?
|
||||
if [ "$_AGY_RC" -ne 0 ]; then
|
||||
: > /tmp/gsd-review-antigravity-{phase}.md
|
||||
@@ -473,9 +503,25 @@ if [ ! -s /tmp/gsd-review-antigravity-{phase}.md ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# Step 3 — final guard: both approaches yielded nothing (auth error, first-run setup, path schema changed, etc.)
|
||||
# Step 3 — final guard: both approaches yielded nothing (auth error, first-run setup,
|
||||
# path schema changed, 404'd pinned model, pre-session stall, etc.)
|
||||
if [ ! -s /tmp/gsd-review-antigravity-{phase}.md ]; then
|
||||
echo "Antigravity review failed or returned empty output." > /tmp/gsd-review-antigravity-{phase}.md
|
||||
{
|
||||
echo "Antigravity review failed or returned empty output."
|
||||
# #2073 mode 2: a pinned model that 404s exits 0 with empty stdout AND an empty
|
||||
# transcript — the only evidence is in agy's own log. Surface it instead of a
|
||||
# bare generic stub so the failure is diagnosable.
|
||||
_AGY_LOG="$HOME/.gemini/antigravity-cli/cli.log"
|
||||
if [ -f "$_AGY_LOG" ]; then
|
||||
_AGY_ERR=$(grep -iE 'agent executor error|NOT_FOUND|Publisher model' "$_AGY_LOG" | tail -3)
|
||||
if [ -n "$_AGY_ERR" ]; then
|
||||
echo "agy log hint (pinned model may be unavailable — run 'agy models' and set review.models.agy):"
|
||||
echo "$_AGY_ERR"
|
||||
fi
|
||||
fi
|
||||
# #2073 mode 3: pre-session stall tell — no new conversation dir appeared.
|
||||
echo "If no agy run started, that is the pre-session-stall case: check whether a new ~/.gemini/antigravity-cli/brain/<conv-id>/ dir appeared within ~30s of launch."
|
||||
} > /tmp/gsd-review-antigravity-{phase}.md
|
||||
fi
|
||||
```
|
||||
|
||||
|
||||
76
hooks/gsd-cursor-pre-tool.js
Normal file
76
hooks/gsd-cursor-pre-tool.js
Normal file
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env node
|
||||
// gsd-hook-version: {{GSD_VERSION}}
|
||||
// gsd-cursor-pre-tool.js — Cursor preToolUse hook (ADR-1239 / #2089)
|
||||
//
|
||||
// Cursor invokes this script before each tool call executes.
|
||||
// Protocol: JSON from Cursor on stdin; JSON response on stdout.
|
||||
//
|
||||
// Input schema (cursor preToolUse):
|
||||
// { tool_name, tool_input, conversation_id, generation_id, model,
|
||||
// hook_event_name, cursor_version, workspace_roots, user_email,
|
||||
// transcript_path }
|
||||
//
|
||||
// Output schema (cursor preToolUse):
|
||||
// { additional_context?: string, block?: boolean, reason?: string }
|
||||
//
|
||||
// Behaviour:
|
||||
// - If a write-class tool targets .planning/, reminds the agent to keep
|
||||
// STATE.md current before the write proceeds.
|
||||
// - Fails open: any error silently exits 0 so a hook bug never wedges Cursor.
|
||||
//
|
||||
// Cursor docs: https://cursor.com/docs/hooks
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const WRITE_TOOL_RE = /write|edit|replace|create|delete|remove|append|apply|patch|insert|mkdir/i;
|
||||
const PATH_KEY_RE = /^(path|file|file_?path|filepath|target_?path|target|dir|directory|uri|filename)$/i;
|
||||
const PLANNING_PATH_RE = /(^|[\\/])\.planning([\\/]|$)/;
|
||||
|
||||
let raw = '';
|
||||
const stdinTimeout = setTimeout(() => {
|
||||
process.exit(0);
|
||||
}, 10000);
|
||||
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', (chunk) => { raw += chunk; });
|
||||
process.stdin.on('end', () => {
|
||||
clearTimeout(stdinTimeout);
|
||||
try {
|
||||
let input;
|
||||
try { input = JSON.parse(raw || '{}'); } catch { process.stdout.write(JSON.stringify({})); return; }
|
||||
|
||||
const toolName = String(
|
||||
input.tool_name || input.toolName || ''
|
||||
).toLowerCase();
|
||||
|
||||
const isWrite = WRITE_TOOL_RE.test(toolName);
|
||||
if (!isWrite) { process.stdout.write(JSON.stringify({})); return; }
|
||||
|
||||
const paths = [];
|
||||
const walk = (v, depth) => {
|
||||
if (depth > 5 || paths.length > 64) return;
|
||||
if (Array.isArray(v)) { for (const x of v) walk(x, depth + 1); return; }
|
||||
if (v && typeof v === 'object') {
|
||||
for (const k of Object.keys(v)) {
|
||||
const val = v[k];
|
||||
if (typeof val === 'string' && PATH_KEY_RE.test(k)) paths.push(val);
|
||||
else walk(val, depth + 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(input.tool_input || input.toolInput || {}, 0);
|
||||
|
||||
if (paths.some((p) => PLANNING_PATH_RE.test(p))) {
|
||||
process.stdout.write(JSON.stringify({
|
||||
additional_context:
|
||||
'GSD: .planning/ write detected — ensure STATE.md reflects the latest phase and progress after this change.',
|
||||
}));
|
||||
return;
|
||||
}
|
||||
} catch { /* fall through to empty response */ }
|
||||
|
||||
process.stdout.write(JSON.stringify({}));
|
||||
});
|
||||
48
hooks/gsd-cursor-stop.js
Normal file
48
hooks/gsd-cursor-stop.js
Normal file
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env node
|
||||
// gsd-hook-version: {{GSD_VERSION}}
|
||||
// gsd-cursor-stop.js — Cursor stop hook (ADR-1239 / #2089)
|
||||
//
|
||||
// Cursor invokes this script when the agent stops responding.
|
||||
// Protocol: JSON from Cursor on stdin; JSON response on stdout.
|
||||
//
|
||||
// Input schema (cursor stop):
|
||||
// { conversation_id, generation_id, model, hook_event_name,
|
||||
// cursor_version, workspace_roots, user_email, transcript_path }
|
||||
//
|
||||
// Output schema (cursor stop):
|
||||
// { additional_context?: string }
|
||||
//
|
||||
// Behaviour:
|
||||
// - Reminds the user to verify work if .planning/ is present.
|
||||
// - Fails open: any error silently exits 0.
|
||||
//
|
||||
// Cursor docs: https://cursor.com/docs/hooks
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
let raw = '';
|
||||
const stdinTimeout = setTimeout(() => {
|
||||
process.exit(0);
|
||||
}, 10000);
|
||||
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', (chunk) => { raw += chunk; });
|
||||
process.stdin.on('end', () => {
|
||||
clearTimeout(stdinTimeout);
|
||||
try {
|
||||
const statePath = path.join(process.cwd(), '.planning', 'STATE.md');
|
||||
if (fs.existsSync(statePath)) {
|
||||
process.stdout.write(JSON.stringify({
|
||||
additional_context:
|
||||
'GSD: Agent stopping — run /gsd:verify-work or /gsd:progress to confirm the phase goal is met before ending the session.',
|
||||
}));
|
||||
} else {
|
||||
process.stdout.write(JSON.stringify({}));
|
||||
}
|
||||
} catch {
|
||||
process.stdout.write(JSON.stringify({}));
|
||||
}
|
||||
});
|
||||
50
hooks/gsd-cursor-subagent-start.js
Normal file
50
hooks/gsd-cursor-subagent-start.js
Normal file
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env node
|
||||
// gsd-hook-version: {{GSD_VERSION}}
|
||||
// gsd-cursor-subagent-start.js — Cursor subagentStart hook (ADR-1239 / #2089)
|
||||
//
|
||||
// Cursor invokes this script when a subagent session starts.
|
||||
// Protocol: JSON from Cursor on stdin; JSON response on stdout.
|
||||
//
|
||||
// Input schema (cursor subagentStart):
|
||||
// { session_id, is_background_agent, conversation_id, generation_id,
|
||||
// model, hook_event_name, cursor_version, workspace_roots,
|
||||
// user_email, transcript_path }
|
||||
//
|
||||
// Output schema (cursor subagentStart):
|
||||
// { additional_context?: string }
|
||||
//
|
||||
// Behaviour:
|
||||
// - Injects a brief GSD state reminder so subagents (planner, executor,
|
||||
// verifier) have the current phase context.
|
||||
// - Fails open: any error silently exits 0.
|
||||
//
|
||||
// Cursor docs: https://cursor.com/docs/hooks
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const MSG_PRESENT =
|
||||
'GSD: Subagent session started — review .planning/STATE.md for the current phase and any blockers before acting.';
|
||||
const MSG_ABSENT =
|
||||
'GSD: Subagent session started — no .planning/ workflow found.';
|
||||
|
||||
let raw = '';
|
||||
const stdinTimeout = setTimeout(() => {
|
||||
process.exit(0);
|
||||
}, 10000);
|
||||
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', (chunk) => { raw += chunk; });
|
||||
process.stdin.on('end', () => {
|
||||
clearTimeout(stdinTimeout);
|
||||
try {
|
||||
const statePath = path.join(process.cwd(), '.planning', 'STATE.md');
|
||||
const statePresent = fs.existsSync(statePath);
|
||||
const msg = statePresent ? MSG_PRESENT : MSG_ABSENT;
|
||||
process.stdout.write(JSON.stringify({ additional_context: msg }));
|
||||
} catch {
|
||||
process.stdout.write(JSON.stringify({}));
|
||||
}
|
||||
});
|
||||
40
hooks/gsd-cursor-subagent-stop.js
Normal file
40
hooks/gsd-cursor-subagent-stop.js
Normal file
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env node
|
||||
// gsd-hook-version: {{GSD_VERSION}}
|
||||
// gsd-cursor-subagent-stop.js — Cursor subagentStop hook (ADR-1239 / #2089)
|
||||
//
|
||||
// Cursor invokes this script when a subagent session completes.
|
||||
// Protocol: JSON from Cursor on stdin; JSON response on stdout.
|
||||
//
|
||||
// Input schema (cursor subagentStop):
|
||||
// { session_id, conversation_id, generation_id, model, hook_event_name,
|
||||
// cursor_version, workspace_roots, user_email, transcript_path }
|
||||
//
|
||||
// Output schema (cursor subagentStop):
|
||||
// { additional_context?: string }
|
||||
//
|
||||
// Behaviour:
|
||||
// - Reminds the orchestrating agent to check the subagent's output.
|
||||
// - Fails open: any error silently exits 0.
|
||||
//
|
||||
// Cursor docs: https://cursor.com/docs/hooks
|
||||
|
||||
'use strict';
|
||||
|
||||
let raw = '';
|
||||
const stdinTimeout = setTimeout(() => {
|
||||
process.exit(0);
|
||||
}, 10000);
|
||||
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', (chunk) => { raw += chunk; });
|
||||
process.stdin.on('end', () => {
|
||||
clearTimeout(stdinTimeout);
|
||||
try {
|
||||
process.stdout.write(JSON.stringify({
|
||||
additional_context:
|
||||
'GSD: Subagent completed — review its output and update .planning/STATE.md if the phase progressed.',
|
||||
}));
|
||||
} catch {
|
||||
process.stdout.write(JSON.stringify({}));
|
||||
}
|
||||
});
|
||||
@@ -21,7 +21,11 @@ const MANAGED_HOOKS = [
|
||||
'gsd-config-reload.js',
|
||||
'gsd-context-monitor.js',
|
||||
'gsd-cursor-post-tool.js',
|
||||
'gsd-cursor-pre-tool.js',
|
||||
'gsd-cursor-session-start.js',
|
||||
'gsd-cursor-stop.js',
|
||||
'gsd-cursor-subagent-start.js',
|
||||
'gsd-cursor-subagent-stop.js',
|
||||
'gsd-ensure-canonical-path.js',
|
||||
'gsd-graphify-update.sh',
|
||||
'gsd-phase-boundary.sh',
|
||||
|
||||
@@ -37,9 +37,13 @@ const HOOKS_TO_COPY = [
|
||||
// so require('./managed-hooks-registry.cjs') resolves in the installed hooks/ dir.
|
||||
'managed-hooks-registry.cjs',
|
||||
'gsd-context-monitor.js',
|
||||
// Cursor lifecycle hooks (issue #777): sessionStart context injection + postToolUse monitor
|
||||
// Cursor lifecycle hooks (#777 + ADR-1239/#2089): 6 managed events
|
||||
'gsd-cursor-session-start.js',
|
||||
'gsd-cursor-post-tool.js',
|
||||
'gsd-cursor-pre-tool.js',
|
||||
'gsd-cursor-stop.js',
|
||||
'gsd-cursor-subagent-start.js',
|
||||
'gsd-cursor-subagent-stop.js',
|
||||
// Claude Code FileChanged hook (#770) — hot-reloads gsd config when
|
||||
// .planning/config.json changes mid-session. Must ship to dist so the
|
||||
// installer can copy it to the target hooks/ dir and register FileChanged.
|
||||
|
||||
@@ -577,6 +577,23 @@ function main() {
|
||||
const MAX_FILES_PER_CHUNK = process.env.RUN_TESTS_MAX_FILES_PER_CHUNK
|
||||
? Number(process.env.RUN_TESTS_MAX_FILES_PER_CHUNK)
|
||||
: 60;
|
||||
// #2088: file COUNT is a poor proxy for a chunk's wall-clock — install-heavy
|
||||
// files (real installs; install-minimal-hooks.test.cjs alone runs ~250 cases
|
||||
// doing dozens of installs) are ~10× a unit file. When several land in the SAME
|
||||
// chunk — e.g. a PR touching the whole install surface, whose *targeted* lane is
|
||||
// unsharded (13 install-heavy files → one chunk) — that chunk blows the 600s
|
||||
// backstop while unit-only chunks finish in seconds. WEIGHT install-heavy files
|
||||
// so they fill a chunk's budget faster and therefore SPREAD across chunks
|
||||
// instead of clustering. Light files keep weight 1, so pure-unit chunking (and
|
||||
// its harness tests) is byte-for-byte unchanged. `MAX_FILES_PER_CHUNK` is now a
|
||||
// per-chunk WEIGHT budget (backwards-compatible: it equals the file count when
|
||||
// every file is light). Tune via RUN_TESTS_HEAVY_FILE_WEIGHT; classify via the
|
||||
// basename prefix (install*/installer*/codex-* are the real install-heavy suites).
|
||||
const HEAVY_TEST_RE = /^(?:install|codex-)/;
|
||||
const HEAVY_FILE_WEIGHT = process.env.RUN_TESTS_HEAVY_FILE_WEIGHT
|
||||
? Number(process.env.RUN_TESTS_HEAVY_FILE_WEIGHT)
|
||||
: 12;
|
||||
const fileWeight = (f) => (HEAVY_TEST_RE.test(basename(f)) ? HEAVY_FILE_WEIGHT : 1);
|
||||
|
||||
// node:test does not exit until the event loop drains. A unit test that leaks
|
||||
// an open handle (un-terminated Worker, un-killed child_process, ref'd timer)
|
||||
@@ -595,18 +612,21 @@ function main() {
|
||||
const chunks = [];
|
||||
let current = [];
|
||||
let currentLen = FIXED_OVERHEAD;
|
||||
let currentWeight = 0;
|
||||
for (const file of selected) {
|
||||
const add = file.length + 1; // +1 for the inter-arg separator
|
||||
if (
|
||||
current.length > 0 &&
|
||||
(currentLen + add > MAX_CMDLINE_CHARS || current.length >= MAX_FILES_PER_CHUNK)
|
||||
(currentLen + add > MAX_CMDLINE_CHARS || currentWeight >= MAX_FILES_PER_CHUNK)
|
||||
) {
|
||||
chunks.push(current);
|
||||
current = [];
|
||||
currentLen = FIXED_OVERHEAD;
|
||||
currentWeight = 0;
|
||||
}
|
||||
current.push(file);
|
||||
currentLen += add;
|
||||
currentWeight += fileWeight(file); // heavy install files count for more
|
||||
}
|
||||
if (current.length > 0) chunks.push(current);
|
||||
|
||||
|
||||
155
src/host-integration-adapters/imperative-hook-bus.cts
Normal file
155
src/host-integration-adapters/imperative-hook-bus.cts
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Imperative hook-bus adapter — descriptor-driven hooks.json binding
|
||||
* (ADR-1239 Phase D / #2089).
|
||||
*
|
||||
* Generalizes the Cursor-specific `writeCursorHooksJson`/`removeCursorHooksJson`
|
||||
* into a descriptor-driven hook-bus binding that reads the negotiated `hookBus`
|
||||
* axis + the host's documented hook-event list (from
|
||||
* `runtime.hostBehaviors.managedHookEvents`), NOT a hardcoded
|
||||
* `sessionStart`/`postToolUse` pair.
|
||||
*
|
||||
* This module is PURE (no I/O): it resolves the event→script mapping and builds
|
||||
* the hooks.json entry manifest. The actual file I/O (copying scripts, writing
|
||||
* hooks.json) stays in `runtime-hooks-surface.cts`, which calls into the pure
|
||||
* functions exported here. This separation makes the binding testable without a
|
||||
* filesystem.
|
||||
*
|
||||
* Cursor hook-event universe (closed vocabulary per ADR-1239,
|
||||
* https://cursor.com/docs/hooks):
|
||||
* sessionStart, sessionEnd, preToolUse, postToolUse, subagentStart,
|
||||
* subagentStop, beforeShellExecution, afterShellExecution,
|
||||
* afterMCPExecution, afterFileEdit, preCompact, stop,
|
||||
* beforeTabFileRead, afterTabFileEdit, workspaceOpen
|
||||
*
|
||||
* GSD registers for the 6 events in the portable floor + subagent lifecycle
|
||||
* (AC4a upgrade, #2089):
|
||||
* sessionStart, postToolUse, preToolUse, stop, subagentStart, subagentStop
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* The full set of Cursor hook events GSD can register for.
|
||||
* Frozen closed vocabulary — adding an event requires updating both this set
|
||||
* and the event→script mapping below.
|
||||
*/
|
||||
export const CURSOR_HOOK_EVENTS = Object.freeze([
|
||||
'sessionStart',
|
||||
'postToolUse',
|
||||
'preToolUse',
|
||||
'stop',
|
||||
'subagentStart',
|
||||
'subagentStop',
|
||||
] as const);
|
||||
|
||||
export type CursorHookEvent = (typeof CURSOR_HOOK_EVENTS)[number];
|
||||
|
||||
/**
|
||||
* Event → hook-script mapping. Each event maps to a standalone `.js` script
|
||||
* under `hooks/` that Cursor invokes via `hooks.json`.
|
||||
*
|
||||
* Convention: `gsd-cursor-<kebab-event>.js`. The script files are authored in
|
||||
* `hooks/` and copied to `<configDir>/hooks/` during install by
|
||||
* `runtime-hooks-surface.cts`.
|
||||
*/
|
||||
export const CURSOR_EVENT_SCRIPT_MAP: Readonly<Record<string, string>> = Object.freeze({
|
||||
sessionStart: 'gsd-cursor-session-start.js',
|
||||
postToolUse: 'gsd-cursor-post-tool.js',
|
||||
preToolUse: 'gsd-cursor-pre-tool.js',
|
||||
stop: 'gsd-cursor-stop.js',
|
||||
subagentStart: 'gsd-cursor-subagent-start.js',
|
||||
subagentStop: 'gsd-cursor-subagent-stop.js',
|
||||
});
|
||||
|
||||
/**
|
||||
* The GSD-managed marker written into each hooks.json entry so the
|
||||
* reconcile pass can distinguish GSD-owned entries from user-owned ones.
|
||||
*/
|
||||
export const GSD_HOOK_MARKER = 'gsd-managed';
|
||||
|
||||
/**
|
||||
* Resolve the managed hook events from a runtime descriptor's
|
||||
* `hostBehaviors.managedHookEvents` list. Falls back to the full
|
||||
* `CURSOR_HOOK_EVENTS` set when the descriptor does not declare the list
|
||||
* (backward-compat for descriptors predating #2089).
|
||||
*
|
||||
* Pure: no I/O, never throws. Unknown event names are silently filtered
|
||||
* (fail-closed — an unrecognized event is never registered). Falls back to
|
||||
* the full CURSOR_HOOK_EVENTS set when the descriptor is absent or all entries
|
||||
* are unrecognized (ensures the portable-event floor is always covered).
|
||||
*
|
||||
* @param managedHookEvents - the descriptor's `hostBehaviors.managedHookEvents` array
|
||||
* @returns a deduplicated, validated array of event names
|
||||
*/
|
||||
export function resolveManagedHookEvents(
|
||||
managedHookEvents: readonly string[] | null | undefined,
|
||||
): readonly string[] {
|
||||
if (!Array.isArray(managedHookEvents) || managedHookEvents.length === 0) {
|
||||
return CURSOR_HOOK_EVENTS;
|
||||
}
|
||||
const valid = new Set<string>(CURSOR_HOOK_EVENTS);
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
for (const ev of managedHookEvents) {
|
||||
if (typeof ev === 'string' && valid.has(ev) && !seen.has(ev)) {
|
||||
seen.add(ev);
|
||||
result.push(ev);
|
||||
}
|
||||
}
|
||||
return result.length > 0 ? result : CURSOR_HOOK_EVENTS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the list of hook script files that need to be copied for the given
|
||||
* managed events. Each event maps to a script via `CURSOR_EVENT_SCRIPT_MAP`.
|
||||
*
|
||||
* Pure: returns a deduplicated array of script filenames.
|
||||
*
|
||||
* @param events - the managed event names (validated by `resolveManagedHookEvents`)
|
||||
* @returns array of script filenames (e.g. `['gsd-cursor-session-start.js', ...]`)
|
||||
*/
|
||||
export function resolveHookScripts(
|
||||
events: readonly string[],
|
||||
): readonly string[] {
|
||||
const scripts: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const ev of events) {
|
||||
const script = CURSOR_EVENT_SCRIPT_MAP[ev];
|
||||
if (script && !seen.has(script)) {
|
||||
seen.add(script);
|
||||
scripts.push(script);
|
||||
}
|
||||
}
|
||||
return scripts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the hooks.json managed-entry manifest for the given events.
|
||||
* Each entry is `{ type: 'command', command: <cmd>, [GSD_HOOK_MARKER]: true }`.
|
||||
*
|
||||
* The `command` string is built by the caller (it requires platform-specific
|
||||
* node-runner resolution from `runtime-hooks-surface.cts`). This function
|
||||
* receives a pre-built `event → command` map and attaches the marker.
|
||||
*
|
||||
* Pure: no I/O.
|
||||
*
|
||||
* @param events - the managed event names
|
||||
* @param commands - a map of event → command string (built by the caller)
|
||||
* @returns a map of event → managed entry, ready for hooks.json reconciliation
|
||||
*/
|
||||
export function buildHookBusEntries(
|
||||
events: readonly string[],
|
||||
commands: Readonly<Record<string, string | null>>,
|
||||
): Record<string, { type: string; command: string; [key: string]: unknown }> {
|
||||
const entries: Record<string, { type: string; command: string; [key: string]: unknown }> = {};
|
||||
for (const ev of events) {
|
||||
const cmd = commands[ev];
|
||||
if (cmd) {
|
||||
entries[ev] = {
|
||||
type: 'command',
|
||||
command: cmd,
|
||||
[GSD_HOOK_MARKER]: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
@@ -555,6 +555,9 @@ const EXTENSION_EVENT_SURFACES: Readonly<Record<string, readonly string[]>> = Ob
|
||||
opencode: Object.freeze([
|
||||
'session.created', 'session.idle', 'experimental.session.compacting',
|
||||
'tool.execute.before', 'tool.execute.after', 'file.edited',
|
||||
// #2087 — additional documented plugin events GSD binds (opencode.ai/docs/plugins):
|
||||
// permission decisions + session error surface.
|
||||
'permission.asked', 'permission.replied', 'session.error',
|
||||
]),
|
||||
pi: Object.freeze(['tool_call']),
|
||||
none: Object.freeze([]),
|
||||
|
||||
17
src/init.cts
17
src/init.cts
@@ -37,7 +37,7 @@ import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- commands.cjs is an export= CommonJS module
|
||||
import commandsMod = require('./commands.cjs');
|
||||
import { validatePath, loadTrustedGlobalRoots } from './security.cjs';
|
||||
import { getGlobalSkillDir, getGlobalSkillDisplayPath, getGlobalSkillsBase } from './runtime-homes.cjs';
|
||||
import { getGlobalSkillDir, getGlobalSkillDisplayPath, getGlobalSkillsBase, getGlobalConfigDir } from './runtime-homes.cjs';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- frontmatter.cjs is an export= CommonJS module
|
||||
import frontmatterMod = require('./frontmatter.cjs');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- verification.cjs is an export= CommonJS module
|
||||
@@ -2420,11 +2420,24 @@ function buildSkillManifest(cwd: string, skillsDir: string | null = null): Skill
|
||||
kind: 'skills',
|
||||
},
|
||||
{
|
||||
root: '~/.codex/skills',
|
||||
// ADR-1239 upgrade 3 (#2088): Codex's canonical skill root is
|
||||
// $HOME/.agents/skills (per codex core-skills loader.rs), resolved via
|
||||
// the skills-kind `home` override in getGlobalSkillsBase.
|
||||
root: '~/.agents/skills',
|
||||
path: getGlobalSkillsBase('codex') as string,
|
||||
scope: 'global',
|
||||
kind: 'skills',
|
||||
},
|
||||
{
|
||||
// Codex's deprecated fallback skill root ($CODEX_HOME/skills). Kept as a
|
||||
// discovery-only legacy root so pre-move installs remain inventoried;
|
||||
// GSD no longer installs here (#2088).
|
||||
root: '~/.codex/skills',
|
||||
path: path.join(getGlobalConfigDir('codex'), 'skills'),
|
||||
scope: 'global',
|
||||
kind: 'skills',
|
||||
deprecated: true,
|
||||
},
|
||||
{
|
||||
root: '.claude/gsd-core/skills',
|
||||
path: path.join(os.homedir(), '.claude', 'gsd-core', 'skills'),
|
||||
|
||||
@@ -26,6 +26,7 @@ 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');
|
||||
import installProfiles = require('./install-profiles.cjs');
|
||||
|
||||
const { processAttribution } = runtimeArtifactConversion;
|
||||
// resolveRuntimeArtifactLayout: accessed via module ref (not destructured) so
|
||||
@@ -65,6 +66,26 @@ type ResolveAttribution = (runtime: string) => any;
|
||||
*/
|
||||
const USER_OWNED_ARTIFACTS: string[] = ['USER-PROFILE.md'];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Host-behavior helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Host-specific install behaviors declared on the runtime descriptor
|
||||
* (capabilities/<runtime>/capability.json -> runtime.hostBehaviors).
|
||||
* Mirrors bin/install.js's `_hostBehaviors` (ADR-1239 / #2086/#2087). Returns
|
||||
* {} for runtimes that declare none or if the registry fails to load, so
|
||||
* every behavior branch degrades to the generic path by default.
|
||||
*/
|
||||
function _hostBehaviors(runtime: string): any {
|
||||
try {
|
||||
const reg = require('./capability-registry.cjs');
|
||||
return (reg && reg.runtimes && reg.runtimes[runtime] && reg.runtimes[runtime].runtime && reg.runtimes[runtime].runtime.hostBehaviors) || {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Conversion helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -255,15 +276,21 @@ function _copyStaged(stagedDir: string, destDir: string, kind: any, configDir: s
|
||||
'_copyStaged: configDir (install root) is required to confine writes — refusing to write',
|
||||
);
|
||||
}
|
||||
// The install root is normally configDir, but a kind may declare an alternate
|
||||
// `home` (ADR-1239 upgrade 3 / #2088, e.g. Codex skills -> $HOME/.agents) — in
|
||||
// that case this defense-in-depth check must confine against the resolved
|
||||
// alternate root instead, matching the upstream gate's own root selection in
|
||||
// createRuntimeArtifactInstallPlan.
|
||||
const installRoot = (kind && typeof kind.home === 'string' && kind.home !== '') ? kind.home : configDir;
|
||||
// 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)) {
|
||||
// destDir here is an absolute path; path.resolve(installRoot, absoluteDest) returns it unchanged, so the gate's strict-subpath check still correctly confines it to installRoot.
|
||||
const resolvedDest = runtimeArtifactInstallPlan.assertDestWithinConfigHome(installRoot, destDir);
|
||||
// Symlink-escape guard: reject if any path component between the install root and
|
||||
// destDir is a symlink that would redirect writes outside the install root.
|
||||
if (hasExistingSymlinkBetween(path.resolve(installRoot), resolvedDest)) {
|
||||
throw new Error(
|
||||
`_copyStaged: destDir "${destDir}" contains a symlink escaping the install root "${configDir}" — refusing to write`,
|
||||
`_copyStaged: destDir "${destDir}" contains a symlink escaping the install root "${installRoot}" — refusing to write`,
|
||||
);
|
||||
}
|
||||
// Use the validated absolute path for the actual writes below.
|
||||
@@ -563,6 +590,16 @@ function installRuntimeArtifacts(
|
||||
resolvedProfile: any,
|
||||
resolveAttribution: ResolveAttribution = () => undefined,
|
||||
): void {
|
||||
// Combined-family runtimes (OpenCode/Kilo, ADR-1239 / #2087): route through
|
||||
// the dedicated combined commands+skills+plugin orchestrator instead of the
|
||||
// generic layout-driven loop below, mirroring the bespoke install path that
|
||||
// previously lived inline in bin/install.js.
|
||||
const behaviors = _hostBehaviors(runtime);
|
||||
if (behaviors.combinedFamilyInstall) {
|
||||
installOpencodeFamilyArtifacts(runtime, configDir, scope, resolvedProfile, resolveAttribution, behaviors);
|
||||
return;
|
||||
}
|
||||
|
||||
// Legacy cleanup before layout-driven writes
|
||||
_runLegacyInstallMigrations(runtime, configDir, scope);
|
||||
|
||||
@@ -589,11 +626,17 @@ function installRuntimeArtifacts(
|
||||
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)) {
|
||||
// between the install root and dest) is a symlink pointing outside that
|
||||
// root. mkdirSync follows symlinks, so this must run BEFORE the mkdir
|
||||
// call. The install root is normally configDir, but a kind may declare
|
||||
// an alternate `home` (ADR-1239 upgrade 3 / #2088, e.g. Codex skills ->
|
||||
// $HOME/.agents) — in that case the guard must check against the
|
||||
// resolved alternate root instead, matching assertDestWithinConfigHome's
|
||||
// own root selection in createRuntimeArtifactInstallPlan.
|
||||
const installRoot = (kind && typeof kind.home === 'string' && kind.home !== '') ? kind.home : configDir;
|
||||
if (hasExistingSymlinkBetween(path.resolve(installRoot), dest)) {
|
||||
throw new Error(
|
||||
`installRuntimeArtifacts: destDir "${dest}" contains a symlink escaping the install root "${configDir}" — refusing to create`,
|
||||
`installRuntimeArtifacts: destDir "${dest}" contains a symlink escaping the install root "${installRoot}" — refusing to create`,
|
||||
);
|
||||
}
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
@@ -690,7 +733,7 @@ function installOpencodeFamilySkills(
|
||||
const rawDir = rawCommandsDir;
|
||||
if (!rawDir || !fs.existsSync(rawDir)) return 0;
|
||||
|
||||
const converter = runtime === 'kilo'
|
||||
const converter = _hostBehaviors(runtime).frontmatterDialect === 'kilo'
|
||||
? convertClaudeCommandToKiloSkill
|
||||
: convertClaudeCommandToOpencodeSkill;
|
||||
|
||||
@@ -743,6 +786,121 @@ function installOpencodeFamilySkills(
|
||||
return count;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// installOpencodeFamilyCommands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Install the flattened commands surface for an OpenCode-family runtime
|
||||
* (OpenCode/Kilo): commands/gsd/**\/*.md -> command/gsd-<...>.md, with
|
||||
* per-runtime frontmatter conversion and path-prefix/attribution rewrites.
|
||||
*
|
||||
* Mirrors bin/install.js's copyFlattenedCommands VERBATIM (ADR-1239 /
|
||||
* #2087), except attribution is resolved via the injected
|
||||
* `resolveAttribution` callback instead of a module-level getCommitAttribution.
|
||||
*
|
||||
* @param runtime - 'opencode' or 'kilo'
|
||||
* @param destDir - destination directory for flattened commands (recurses with the same destDir)
|
||||
* @param srcDir - source directory to walk (commands/gsd/, recursing into subdirectories)
|
||||
* @param pathPrefix - computed config-path prefix for body rewrites
|
||||
* @param resolveAttribution - injection: (runtime) => attribution string | undefined
|
||||
* @param prefix - filename prefix accumulator (defaults to 'gsd'; grows on recursion)
|
||||
*/
|
||||
function installOpencodeFamilyCommands(
|
||||
runtime: string,
|
||||
destDir: string,
|
||||
srcDir: string,
|
||||
pathPrefix: string,
|
||||
resolveAttribution: ResolveAttribution = () => undefined,
|
||||
prefix: string = 'gsd',
|
||||
): void {
|
||||
if (!fs.existsSync(srcDir)) return;
|
||||
|
||||
// Remove old gsd-*.md files before copying new ones
|
||||
if (fs.existsSync(destDir)) {
|
||||
for (const file of fs.readdirSync(destDir)) {
|
||||
if (file.startsWith(`${prefix}-`) && file.endsWith('.md')) fs.unlinkSync(path.join(destDir, file));
|
||||
}
|
||||
} else {
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
}
|
||||
|
||||
for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
|
||||
const srcPath = path.join(srcDir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
installOpencodeFamilyCommands(runtime, destDir, srcPath, pathPrefix, resolveAttribution, `${prefix}-${entry.name}`);
|
||||
} else if (entry.name.endsWith('.md')) {
|
||||
const baseName = entry.name.replace('.md', '');
|
||||
const destName = `${prefix}-${baseName}.md`;
|
||||
let content = fs.readFileSync(srcPath, 'utf8');
|
||||
content = applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix);
|
||||
content = processAttribution(content, resolveAttribution(runtime));
|
||||
content = _hostBehaviors(runtime).frontmatterDialect === 'kilo'
|
||||
? (runtimeArtifactConversion as any).convertClaudeToKiloFrontmatter(content)
|
||||
: (runtimeArtifactConversion as any).convertClaudeToOpencodeFrontmatter(content);
|
||||
fs.writeFileSync(path.join(destDir, destName), content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// installOpencodeFamilyArtifacts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Combined-family install orchestrator for OpenCode/Kilo (ADR-1239 / #2087).
|
||||
* Stages the flattened commands surface + skills surface + (OpenCode only)
|
||||
* native plugin adapter, mirroring the bespoke `else if (isOpencode ||
|
||||
* isKilo)` block previously inlined in bin/install.js.
|
||||
*
|
||||
* @param runtime - 'opencode' or 'kilo'
|
||||
* @param configDir - resolved runtime config directory
|
||||
* @param scope - install scope ('global' | 'local')
|
||||
* @param resolvedProfile - from resolveProfile() / resolveEffectiveProfile()
|
||||
* @param resolveAttribution - injection: (runtime) => attribution string | undefined
|
||||
* @param behaviors - the runtime's hostBehaviors descriptor (already resolved by the caller)
|
||||
*/
|
||||
function installOpencodeFamilyArtifacts(
|
||||
runtime: string,
|
||||
configDir: string,
|
||||
scope: string,
|
||||
resolvedProfile: any,
|
||||
resolveAttribution: ResolveAttribution = () => undefined,
|
||||
behaviors: any = {},
|
||||
): void {
|
||||
const isGlobal = scope === 'global';
|
||||
// findInstallSourceRoot resolves DIRECTLY to the commands/gsd source dir
|
||||
// (via the .gsd-source marker or a walk-up from __dirname) — every other
|
||||
// call site in runtime-artifact-layout.cts feeds its return value straight
|
||||
// into stageSkillsForProfile/stageSkillsForRuntimeAsSkills. The repo/package
|
||||
// root (needed below for the native plugin source) is two levels up.
|
||||
const commandsGsdDir = runtimeArtifactLayout.findInstallSourceRoot(configDir);
|
||||
const src = path.dirname(path.dirname(commandsGsdDir));
|
||||
const rawCommandsDir = installProfiles.stageSkillsForProfile(commandsGsdDir, resolvedProfile);
|
||||
|
||||
const pathPrefix = (runtimeArtifactConversion as any)._computePathPrefix({
|
||||
isGlobal,
|
||||
isOpencode: behaviors.skipHomePrefixSubstitution === true,
|
||||
isWindowsHost: process.platform === 'win32',
|
||||
resolvedTarget: path.resolve(configDir).replace(/\\/g, '/'),
|
||||
homeDir: os.homedir().replace(/\\/g, '/'),
|
||||
});
|
||||
|
||||
const commandDir = runtimeArtifactInstallPlan.assertDestWithinConfigHome(configDir, 'command');
|
||||
installOpencodeFamilyCommands(runtime, commandDir, rawCommandsDir, pathPrefix, resolveAttribution);
|
||||
installOpencodeFamilySkills(runtime, configDir, rawCommandsDir, pathPrefix, resolveAttribution);
|
||||
|
||||
const np = behaviors.nativePlugin;
|
||||
if (np && np.source) {
|
||||
const pluginSrc = path.join(src, np.source);
|
||||
if (fs.existsSync(pluginSrc)) {
|
||||
const destDir = runtimeArtifactInstallPlan.assertDestWithinConfigHome(configDir, np.dir);
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
fs.copyFileSync(pluginSrc, path.join(destDir, np.file));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// uninstallRuntimeArtifacts
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -807,6 +965,9 @@ export = {
|
||||
installRuntimeArtifacts,
|
||||
uninstallRuntimeArtifacts,
|
||||
installOpencodeFamilySkills,
|
||||
installOpencodeFamilyCommands,
|
||||
installOpencodeFamilyArtifacts,
|
||||
_hostBehaviors,
|
||||
_copyStaged,
|
||||
hasExistingSymlinkBetween,
|
||||
preserveUserArtifacts,
|
||||
|
||||
@@ -32,7 +32,11 @@ export const BUNDLED_GSD_HOOK_FILES: ReadonlySet<string> = Object.freeze(new Set
|
||||
'hooks/gsd-config-reload.js',
|
||||
'hooks/gsd-context-monitor.js',
|
||||
'hooks/gsd-cursor-post-tool.js',
|
||||
'hooks/gsd-cursor-pre-tool.js',
|
||||
'hooks/gsd-cursor-session-start.js',
|
||||
'hooks/gsd-cursor-stop.js',
|
||||
'hooks/gsd-cursor-subagent-start.js',
|
||||
'hooks/gsd-cursor-subagent-stop.js',
|
||||
'hooks/gsd-ensure-canonical-path.js',
|
||||
'hooks/gsd-graphify-update.sh',
|
||||
'hooks/gsd-phase-boundary.sh',
|
||||
|
||||
@@ -255,6 +255,97 @@ function phaseTokenMatches(dirName: string, normalized: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ─── #2121 canonical surface (ADR-2121) ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse a phase identifier from a STATE.md `Phase:` prose field VALUE — the text
|
||||
* after the `Phase:` label (e.g. `"3 of 4 (Delta)"`, `"3A — Delta (executing)"`,
|
||||
* or `"Milestone v0.5 complete"`).
|
||||
*
|
||||
* The token is anchored to the START of the value (after an optional literal
|
||||
* `Phase ` label and an optional project-code prefix) so a phase is only
|
||||
* returned when the value actually begins with one. This is the #2111 fix: the
|
||||
* prior unanchored `/\b(\d+[A-Z]?(?:\.\d+)*)\b/i` mined the first numeral
|
||||
* anywhere, so `"Milestone v0.5 complete"` collapsed to `"5"` (the minor-version
|
||||
* digit) and `"v1.0"` to `"0"` (a reserved sentinel). Here both yield
|
||||
* `{ phase: null }` because they do not begin with a phase token. The name
|
||||
* extraction (parenthetical or em-dash tail, minus status words) is unchanged.
|
||||
*/
|
||||
function parsePhaseFromProse(value: string | null): { phase: string | null; name: string | null } {
|
||||
if (!value) return { phase: null, name: null };
|
||||
// Coerce defensively so a non-string caller cannot throw on this canonical
|
||||
// surface (mirrors the sibling #2121 functions' String(...) handling).
|
||||
const str = String(value);
|
||||
const phaseMatch = str.match(/^\s*(?:Phase\s+)?(?:[A-Z][A-Z0-9_]*-)?(\d+[A-Z]?(?:\.\d+)*)\b/i);
|
||||
// The name-extraction quantifiers are length-bounded so a crafted long
|
||||
// unterminated run (many `(` or `—`) in an untrusted STATE.md field value
|
||||
// cannot drive O(n^2) regex backtracking (CPU-exhaustion DoS). A real phase
|
||||
// name is far shorter than the cap.
|
||||
const parenName = str.match(/\(([^)]{1,200})\)/);
|
||||
const dashName = str.match(/—\s*([^(\n]{1,200}?)(?:\s*\(|$)/);
|
||||
const rawName = parenName?.[1] ?? dashName?.[1] ?? null;
|
||||
const name = rawName && !/^(?:complete|executing|not started)$/i.test(rawName.trim())
|
||||
? rawName.trim()
|
||||
: null;
|
||||
return {
|
||||
phase: phaseMatch ? phaseMatch[1] : null,
|
||||
name,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Config-AWARE project-code prefix strip. Unlike the config-blind
|
||||
* `stripProjectCodePrefix` (which strips ANY `<CODE>-` shape), this strips the
|
||||
* leading `<CODE>-` ONLY when `<CODE>` case-insensitively equals the configured
|
||||
* `projectCode`. A foreign prefix (`MEM-01` when the configured code is `LKML`)
|
||||
* or an absent/empty `projectCode` is preserved verbatim — this is the #2104
|
||||
* fix: a foreign-prefixed id must not collapse to a bare numeric phase and
|
||||
* collide with a real one.
|
||||
*/
|
||||
function stripConfiguredProjectCodePrefix(value: unknown, projectCode: string | null | undefined): string {
|
||||
const input = String(value);
|
||||
const configured = typeof projectCode === 'string' ? projectCode.trim() : '';
|
||||
if (!configured) return input;
|
||||
const m = input.match(PROJECT_CODE_PREFIX_CAPTURE_RE_I);
|
||||
if (!m) return input;
|
||||
if (m[1].toUpperCase() !== configured.toUpperCase()) return input;
|
||||
return m[2];
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `phase` carries a project-code prefix that is NOT the configured
|
||||
* `projectCode` (or when no `projectCode` is configured). The canonical
|
||||
* predicate the init-command foreign-prefix guard (#2056 / PR #2105) delegates
|
||||
* to, so every call site shares one foreign-prefix rule.
|
||||
*/
|
||||
function isForeignPrefixedPhaseQuery(phase: unknown, projectCode: unknown): boolean {
|
||||
const m = String(phase).match(PROJECT_CODE_PREFIX_CAPTURE_RE_I);
|
||||
if (!m) return false;
|
||||
const configured = typeof projectCode === 'string' ? projectCode.trim() : '';
|
||||
return !configured || m[1].toUpperCase() !== configured.toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical ROADMAP heading lookup-source list (moved here from
|
||||
* roadmap-parser.cts so phase-id.cts is the single owner of the ordering).
|
||||
* Sources are tried in a fixed, deduplicated order: exact (only when the query
|
||||
* itself is project-code-prefixed) → bare numeric / padding-tolerant →
|
||||
* prefix-tolerant fallback. The bare numeric source precedes the prefix-tolerant
|
||||
* form so a canonical heading (`### Phase 117:`) is preferred over a drifted
|
||||
* prefixed one (`### Phase MANIFOLD-117:`) when both exist in one ROADMAP.
|
||||
*/
|
||||
function roadmapPhaseLookupSources(phaseNum: unknown): string[] {
|
||||
const sources: string[] = [];
|
||||
const exactSource = phaseMarkdownRegexSourceExact(phaseNum);
|
||||
if (exactSource) sources.push(exactSource);
|
||||
|
||||
const numericSource = phaseMarkdownRegexSource(phaseNum);
|
||||
sources.push(numericSource);
|
||||
sources.push(`${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${numericSource}`);
|
||||
|
||||
return [...new Set(sources)];
|
||||
}
|
||||
|
||||
export = {
|
||||
escapeRegex,
|
||||
OPTIONAL_PROJECT_CODE_PREFIX_SOURCE,
|
||||
@@ -268,4 +359,8 @@ export = {
|
||||
comparePhaseNum,
|
||||
extractPhaseToken,
|
||||
phaseTokenMatches,
|
||||
parsePhaseFromProse,
|
||||
stripConfiguredProjectCodePrefix,
|
||||
isForeignPrefixedPhaseQuery,
|
||||
roadmapPhaseLookupSources,
|
||||
};
|
||||
|
||||
@@ -22,10 +22,11 @@ import phaseIdModule = require('./phase-id.cjs');
|
||||
const {
|
||||
escapeRegex,
|
||||
phaseMarkdownRegexSource,
|
||||
phaseMarkdownRegexSourceExact,
|
||||
stripProjectCodePrefix,
|
||||
OPTIONAL_PROJECT_CODE_PREFIX_SOURCE,
|
||||
OPTIONAL_PHASE_TAG_SOURCE,
|
||||
// #2121: roadmapPhaseLookupSources now lives in phase-id.cjs (single owner of
|
||||
// the lookup-source ordering); imported here rather than defined locally.
|
||||
roadmapPhaseLookupSources,
|
||||
} = phaseIdModule;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import planningWorkspace = require('./planning-workspace.cjs');
|
||||
@@ -242,23 +243,6 @@ function findRoadmapPhaseInContent(content: string, phaseNum: unknown, phaseSour
|
||||
};
|
||||
}
|
||||
|
||||
function roadmapPhaseLookupSources(phaseNum: unknown): string[] {
|
||||
const sources: string[] = [];
|
||||
const exactSource = phaseMarkdownRegexSourceExact(phaseNum);
|
||||
if (exactSource) sources.push(exactSource);
|
||||
|
||||
const numericSource = phaseMarkdownRegexSource(phaseNum);
|
||||
// Source order matters: the bare numeric source is tried before the
|
||||
// prefix-tolerant form so that a canonical bare heading ("Phase 117:") is
|
||||
// preferred over a drifted prefixed heading ("Phase MANIFOLD-117:") when
|
||||
// both exist in the same ROADMAP. The prefix-tolerant form is the fallback
|
||||
// that handles the drifted-only case.
|
||||
sources.push(numericSource);
|
||||
sources.push(`${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${numericSource}`);
|
||||
|
||||
return [...new Set(sources)];
|
||||
}
|
||||
|
||||
function getRoadmapPhaseInternal(cwd: string, phaseNum: unknown): RoadmapPhaseResult | null {
|
||||
if (!phaseNum) return null;
|
||||
const normalizedPhase = stripProjectCodePrefix(phaseNum);
|
||||
|
||||
@@ -2507,7 +2507,7 @@ function rewriteStagedSkillBodies(stagedDir, opts) {
|
||||
const resolvedTarget = path.resolve(configDir).replace(/\\/g, '/');
|
||||
const homeDir = homedir().replace(/\\/g, '/');
|
||||
const isGlobal = scope === 'global';
|
||||
const isOpencode = runtime === 'opencode';
|
||||
const isOpencode = false; // #2087: opencode installs via the combined-family engine path, never through the generic rewrite
|
||||
const isWindowsHost = platform === 'win32';
|
||||
const pathPrefix = computePathPrefix({ isGlobal, isOpencode, isWindowsHost, resolvedTarget, homeDir });
|
||||
const attribution = resolveAttribution ? resolveAttribution(runtime) : undefined;
|
||||
@@ -2543,7 +2543,7 @@ function rewriteStagedCommandBodies(stagedDir, opts) {
|
||||
const resolvedTarget = path.resolve(configDir).replace(/\\/g, '/');
|
||||
const homeDir = homedir().replace(/\\/g, '/');
|
||||
const isGlobal = scope === 'global';
|
||||
const isOpencode = runtime === 'opencode';
|
||||
const isOpencode = false; // #2087: opencode installs via the combined-family engine path, never through the generic rewrite
|
||||
const isWindowsHost = platform === 'win32';
|
||||
const pathPrefix = computePathPrefix({ isGlobal, isOpencode, isWindowsHost, resolvedTarget, homeDir });
|
||||
const attribution = resolveAttribution ? resolveAttribution(runtime) : undefined;
|
||||
@@ -2672,6 +2672,11 @@ export = {
|
||||
neutralizeAgentReferences,
|
||||
convertClaudeCommandToOpencodeSkill,
|
||||
convertClaudeCommandToKiloSkill,
|
||||
// #2087 — opencode/kilo command-frontmatter converters, exported so the
|
||||
// layout-driven `convertedCommandsKind` can resolve them by name (routes the
|
||||
// opencode/kilo command install through the engine instead of the bespoke path).
|
||||
convertClaudeToOpencodeFrontmatter,
|
||||
convertClaudeToKiloFrontmatter,
|
||||
readGsdCommandNames,
|
||||
transformContentToHyphen,
|
||||
// #1383: version resolver (exported for regression test of the Codex
|
||||
|
||||
@@ -32,6 +32,10 @@ interface ArtifactKind {
|
||||
destSubpath: string;
|
||||
prefix?: string;
|
||||
stage: (resolvedProfile: ResolvedProfile, agentCtx?: AgentCtx) => string;
|
||||
/** Resolved absolute alternate install root for this kind, if the descriptor
|
||||
* specifies one (e.g. codex skills → $HOME/.agents). Undefined means the
|
||||
* kind installs under the runtime's normal configDir. */
|
||||
home?: string;
|
||||
}
|
||||
|
||||
interface Layout {
|
||||
@@ -216,7 +220,7 @@ function createRuntimeArtifactInstallPlan(args: CreateRuntimeArtifactInstallPlan
|
||||
items.push({
|
||||
kind: kind.kind,
|
||||
sourceDir,
|
||||
destDir: assertDestWithinConfigHome(layout.configDir, kind.destSubpath),
|
||||
destDir: assertDestWithinConfigHome(kind.home ?? layout.configDir, kind.destSubpath),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -227,7 +231,7 @@ function createRuntimeArtifactUninstallPlan(layout: Layout): UninstallPlan {
|
||||
return {
|
||||
items: layout.kinds.map((kind) => ({
|
||||
kind: kind.kind,
|
||||
destDir: assertDestWithinConfigHome(layout.configDir, kind.destSubpath),
|
||||
destDir: assertDestWithinConfigHome(kind.home ?? layout.configDir, kind.destSubpath),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -73,6 +73,10 @@ interface ArtifactKind {
|
||||
/** 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;
|
||||
/** Resolved absolute alternate install root for this kind, if the descriptor
|
||||
* specifies one (e.g. codex skills → $HOME/.agents). Undefined means the
|
||||
* kind installs under the runtime's normal configDir. */
|
||||
home?: string;
|
||||
}
|
||||
|
||||
interface Layout {
|
||||
@@ -419,6 +423,10 @@ interface ArtifactKindDescriptor {
|
||||
nesting: 'flat' | 'nested';
|
||||
recursive: boolean;
|
||||
converter: string | null;
|
||||
/** Optional alternate install home, relative to the user's home directory
|
||||
* (e.g. ".agents" for codex skills → $HOME/.agents/skills). When absent,
|
||||
* the kind installs under the runtime's normal configDir. */
|
||||
home?: string;
|
||||
}
|
||||
|
||||
interface ArtifactLayoutDescriptor {
|
||||
@@ -445,18 +453,19 @@ function dispatchKindEntry(entry: ArtifactKindDescriptor, runtime: string, confi
|
||||
const { kind, destSubpath, prefix, nesting, converter } = entry;
|
||||
const nested = nesting === 'nested';
|
||||
|
||||
let result: ArtifactKind;
|
||||
switch (kind) {
|
||||
case 'commands':
|
||||
if (converter == null) {
|
||||
return commandsKind(destSubpath, prefix, configDir);
|
||||
}
|
||||
return convertedCommandsKind(destSubpath, prefix, converter, configDir);
|
||||
result = converter == null
|
||||
? commandsKind(destSubpath, prefix, configDir)
|
||||
: convertedCommandsKind(destSubpath, prefix, converter, configDir);
|
||||
break;
|
||||
|
||||
case 'agents':
|
||||
if (converter == null) {
|
||||
return agentsKind(destSubpath, prefix, configDir);
|
||||
}
|
||||
return convertedAgentsKind(destSubpath, prefix, converter, configDir, scope);
|
||||
result = converter == null
|
||||
? agentsKind(destSubpath, prefix, configDir)
|
||||
: convertedAgentsKind(destSubpath, prefix, converter, configDir, scope);
|
||||
break;
|
||||
|
||||
case 'skills':
|
||||
if (converter == null) {
|
||||
@@ -464,16 +473,24 @@ function dispatchKindEntry(entry: ArtifactKindDescriptor, runtime: string, confi
|
||||
`resolveRuntimeArtifactLayout: skills entry for '${runtime}' has converter=null (converter is required for skills)`,
|
||||
);
|
||||
}
|
||||
return skillsKind(destSubpath, prefix, converter, runtime, configDir, nested, scope);
|
||||
result = skillsKind(destSubpath, prefix, converter, runtime, configDir, nested, scope);
|
||||
break;
|
||||
|
||||
case 'kimi-agents':
|
||||
return kimiAgentsKind(destSubpath, prefix, configDir);
|
||||
result = kimiAgentsKind(destSubpath, prefix, configDir);
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new TypeError(
|
||||
`resolveRuntimeArtifactLayout: unknown kind '${kind}' in descriptor for runtime '${runtime}'`,
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof entry.home === 'string' && entry.home !== '') {
|
||||
result.home = path.join(os.homedir(), entry.home);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -118,6 +118,9 @@ type ConfigHomeDescriptor =
|
||||
interface RuntimeArtifactKindDescriptor {
|
||||
kind: string;
|
||||
destSubpath: string;
|
||||
// ADR-1239 upgrade 3 (#2088): optional split-home override (relative to
|
||||
// os.homedir()), e.g. Codex skills → ".agents". Absent for most runtimes.
|
||||
home?: string;
|
||||
}
|
||||
|
||||
interface RuntimeDescriptor {
|
||||
@@ -416,6 +419,14 @@ export function getGlobalSkillsBase(runtime: string): string | null {
|
||||
const runtimeEntry = getRegistry().runtimes[runtime];
|
||||
const descriptor = runtimeEntry?.runtime;
|
||||
const globalSkillsKind = descriptor?.artifactLayout?.global?.find((entry) => entry.kind === 'skills');
|
||||
// ADR-1239 upgrade 3 (#2088): honor a skills-kind `home` override (e.g. Codex
|
||||
// → $HOME/.agents/skills, independent of $CODEX_HOME) so the reported skills
|
||||
// root matches where the installer actually writes (the artifact layout /
|
||||
// _resolveSkillsRootDir). Without this, `--skills-root` and the sync-skills
|
||||
// workflow would look under configHome/skills while skills live under ~/.agents.
|
||||
if (globalSkillsKind?.home && globalSkillsKind?.destSubpath) {
|
||||
return path.join(os.homedir(), globalSkillsKind.home, globalSkillsKind.destSubpath);
|
||||
}
|
||||
if (descriptor?.configHome && globalSkillsKind?.destSubpath) {
|
||||
return resolveSkillsBaseFromDescriptor(
|
||||
descriptor.configHome,
|
||||
|
||||
@@ -27,6 +27,13 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import {
|
||||
CURSOR_HOOK_EVENTS,
|
||||
CURSOR_EVENT_SCRIPT_MAP,
|
||||
resolveManagedHookEvents,
|
||||
resolveHookScripts,
|
||||
buildHookBusEntries,
|
||||
} from './host-integration-adapters/imperative-hook-bus.cjs';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import shellCmdProjection = require('./shell-command-projection.cjs');
|
||||
const {
|
||||
@@ -80,8 +87,20 @@ const GSD_COPILOT_SESSION_HOOK_PWSH =
|
||||
// ---------------------------------------------------------------------------
|
||||
const GSD_CURSOR_SESSION_HOOK_SCRIPT = 'gsd-cursor-session-start.js';
|
||||
const GSD_CURSOR_POST_TOOL_HOOK_SCRIPT = 'gsd-cursor-post-tool.js';
|
||||
const GSD_CURSOR_PRE_TOOL_HOOK_SCRIPT = 'gsd-cursor-pre-tool.js';
|
||||
const GSD_CURSOR_STOP_HOOK_SCRIPT = 'gsd-cursor-stop.js';
|
||||
const GSD_CURSOR_SUBAGENT_START_HOOK_SCRIPT = 'gsd-cursor-subagent-start.js';
|
||||
const GSD_CURSOR_SUBAGENT_STOP_HOOK_SCRIPT = 'gsd-cursor-subagent-stop.js';
|
||||
const GSD_CURSOR_HOOK_MARKER = 'gsd-managed';
|
||||
|
||||
// The full set of Cursor hook events GSD manages — sourced from the adapter
|
||||
// (src/host-integration-adapters/imperative-hook-bus.cts) so the vocabulary
|
||||
// stays closed and first-party. Used by reconcileCursorHooksJson (the
|
||||
// reconciliation scope is always the full set). The install path
|
||||
// (writeCursorHooksJson) resolves a descriptor-driven subset via
|
||||
// resolveManagedHookEvents(opts.managedHookEvents).
|
||||
const CURSOR_MANAGED_EVENTS = CURSOR_HOOK_EVENTS;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cline / AGENTS.md constants
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -976,9 +995,8 @@ function reconcileCursorHooksJson(hooksJsonPath: string, managedEntries: CursorM
|
||||
const hasNestedHooksObject =
|
||||
parsed['hooks'] && typeof parsed['hooks'] === 'object' && !Array.isArray(parsed['hooks']);
|
||||
if (!hasNestedHooksObject) {
|
||||
const eventKeys = ['sessionStart', 'postToolUse'];
|
||||
const lifted: Record<string, unknown> = {};
|
||||
for (const k of eventKeys) {
|
||||
for (const k of CURSOR_MANAGED_EVENTS) {
|
||||
if (Array.isArray(parsed[k])) {
|
||||
lifted[k] = parsed[k];
|
||||
delete parsed[k];
|
||||
@@ -989,10 +1007,9 @@ function reconcileCursorHooksJson(hooksJsonPath: string, managedEntries: CursorM
|
||||
if (!parsed['version']) parsed['version'] = 1;
|
||||
const hookTable = parsed['hooks'] as Record<string, unknown>;
|
||||
|
||||
const MANAGED_EVENTS = ['sessionStart', 'postToolUse'];
|
||||
const entries = managedEntries || {};
|
||||
|
||||
for (const event of MANAGED_EVENTS) {
|
||||
for (const event of CURSOR_MANAGED_EVENTS) {
|
||||
const existing = Array.isArray(hookTable[event]) ? (hookTable[event] as unknown[]) : [];
|
||||
const userOwned = existing.filter((e) => !isManagedCursorHookEntry(e));
|
||||
const newEntry = entries[event] || null;
|
||||
@@ -1020,6 +1037,7 @@ function reconcileCursorHooksJson(hooksJsonPath: string, managedEntries: CursorM
|
||||
interface WriteCursorHooksJsonOpts {
|
||||
absoluteRunner?: string | null;
|
||||
platform?: string;
|
||||
managedHookEvents?: readonly string[];
|
||||
}
|
||||
|
||||
function writeCursorHooksJson(targetDir: string, src: string, opts?: WriteCursorHooksJsonOpts): { hooksJsonPath: string; changed: boolean } {
|
||||
@@ -1027,7 +1045,11 @@ function writeCursorHooksJson(targetDir: string, src: string, opts?: WriteCursor
|
||||
const hooksDir = path.join(targetDir, 'hooks');
|
||||
fs.mkdirSync(hooksDir, { recursive: true });
|
||||
|
||||
const hookScripts = [GSD_CURSOR_SESSION_HOOK_SCRIPT, GSD_CURSOR_POST_TOOL_HOOK_SCRIPT];
|
||||
// Descriptor-driven event resolution (#2089): the managed event set comes
|
||||
// from the host descriptor's hostBehaviors.managedHookEvents via the pure
|
||||
// adapter (resolveManagedHookEvents), NOT a hardcoded constant.
|
||||
const events = resolveManagedHookEvents(opts.managedHookEvents);
|
||||
const hookScripts = resolveHookScripts(events);
|
||||
const srcHooksDir = path.join(src, 'hooks');
|
||||
const installedScripts = new Set<string>();
|
||||
for (const script of hookScripts) {
|
||||
@@ -1043,28 +1065,16 @@ function writeCursorHooksJson(targetDir: string, src: string, opts?: WriteCursor
|
||||
}
|
||||
|
||||
const hookOpts: BuildHookCommandOpts = { runtime: 'cursor', platform: opts.platform || process.platform };
|
||||
const sessionStartCmd = installedScripts.has('gsd-cursor-session-start.js')
|
||||
? buildHookCommand(targetDir, 'gsd-cursor-session-start.js', hookOpts)
|
||||
: null;
|
||||
const postToolCmd = installedScripts.has('gsd-cursor-post-tool.js')
|
||||
? buildHookCommand(targetDir, 'gsd-cursor-post-tool.js', hookOpts)
|
||||
: null;
|
||||
|
||||
const managedEntries: CursorManagedEntries = {};
|
||||
if (sessionStartCmd) {
|
||||
managedEntries['sessionStart'] = {
|
||||
type: 'command',
|
||||
command: sessionStartCmd,
|
||||
[GSD_CURSOR_HOOK_MARKER]: true,
|
||||
};
|
||||
}
|
||||
if (postToolCmd) {
|
||||
managedEntries['postToolUse'] = {
|
||||
type: 'command',
|
||||
command: postToolCmd,
|
||||
[GSD_CURSOR_HOOK_MARKER]: true,
|
||||
};
|
||||
const commands: Record<string, string | null> = {};
|
||||
for (const ev of events) {
|
||||
const script = CURSOR_EVENT_SCRIPT_MAP[ev];
|
||||
if (script && installedScripts.has(script)) {
|
||||
commands[ev] = buildHookCommand(targetDir, script, hookOpts);
|
||||
} else {
|
||||
commands[ev] = null;
|
||||
}
|
||||
}
|
||||
const managedEntries = buildHookBusEntries(events, commands) as CursorManagedEntries;
|
||||
|
||||
const hooksJsonPath = path.join(targetDir, 'hooks.json');
|
||||
const result = reconcileCursorHooksJson(hooksJsonPath, managedEntries);
|
||||
@@ -1729,6 +1739,10 @@ export = {
|
||||
removeCursorHooksJson,
|
||||
GSD_CURSOR_SESSION_HOOK_SCRIPT,
|
||||
GSD_CURSOR_POST_TOOL_HOOK_SCRIPT,
|
||||
GSD_CURSOR_PRE_TOOL_HOOK_SCRIPT,
|
||||
GSD_CURSOR_STOP_HOOK_SCRIPT,
|
||||
GSD_CURSOR_SUBAGENT_START_HOOK_SCRIPT,
|
||||
GSD_CURSOR_SUBAGENT_STOP_HOOK_SCRIPT,
|
||||
GSD_CURSOR_HOOK_MARKER,
|
||||
|
||||
// Copilot
|
||||
|
||||
@@ -1118,8 +1118,12 @@ function matchSessionSection(body: string): RegExpMatchArray | null {
|
||||
function parseProsePhaseField(value: string | null): { phase: string | null; name: string | null } {
|
||||
if (!value) return { phase: null, name: null };
|
||||
const phaseMatch = value.match(/\b(\d+[A-Z]?(?:\.\d+)*)\b/i);
|
||||
const parenName = value.match(/\(([^)]+)\)/);
|
||||
const dashName = value.match(/—\s*([^(\n]+?)(?:\s*\(|$)/);
|
||||
// #2124 review: length-bound the name quantifiers so a crafted long
|
||||
// unterminated `(` / `—` run in an untrusted STATE.md field cannot drive
|
||||
// O(n^2) backtracking (CPU DoS). (Phase 2 / #2125 supersedes this function
|
||||
// by delegating to phase-id.cts:parsePhaseFromProse, which is bounded too.)
|
||||
const parenName = value.match(/\(([^)]{1,200})\)/);
|
||||
const dashName = value.match(/—\s*([^(\n]{1,200}?)(?:\s*\(|$)/);
|
||||
const rawName = parenName?.[1] ?? dashName?.[1] ?? null;
|
||||
const name = rawName && !/^(?:complete|executing|not started)$/i.test(rawName.trim())
|
||||
? rawName.trim()
|
||||
|
||||
109
tests/antigravity-reviewer.test.cjs
Normal file
109
tests/antigravity-reviewer.test.cjs
Normal file
@@ -0,0 +1,109 @@
|
||||
// allow-test-rule: source-text-is-the-product (see #2073)
|
||||
// gsd-core/workflows/review.md is a workflow document whose bash blocks ARE
|
||||
// what /gsd-review loads and executes at runtime. Asserting the Antigravity
|
||||
// invocation shape asserts the deployed contract — this is behavioral coverage
|
||||
// of the workflow, not a source-grep over application code.
|
||||
|
||||
/**
|
||||
* Antigravity (agy) reviewer invocation tests (#2073)
|
||||
*
|
||||
* The agy block in /gsd-review had three real-world failure modes on agy 1.0.16:
|
||||
* 1. inline `-p "$(cat <prompt>)"` overflowed the exec arg list on a large prompt
|
||||
* 2. a pinned model that 404'd exited 0 with empty stdout AND an empty transcript
|
||||
* (no --model escape hatch; the generic Step 3 stub gave no diagnostic)
|
||||
* 3. a pre-session stall hung past --print-timeout (which can't fire before a
|
||||
* session exists) because there was no external wall-clock `timeout`
|
||||
* Plus a stale maintainer note claiming agy has no --model flag.
|
||||
*
|
||||
* These tests pin the corrected invocation shape in gsd-core/workflows/review.md.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const REVIEW_PATH = path.join(ROOT, 'gsd-core', 'workflows', 'review.md');
|
||||
|
||||
function agyBashBlock() {
|
||||
const content = fs.readFileSync(REVIEW_PATH, 'utf-8');
|
||||
const fences = content.match(/```bash[\s\S]*?```/g) || [];
|
||||
// Target the INVOCATION block (the fence that writes the antigravity review
|
||||
// output), not the `command -v agy` detection one-liner.
|
||||
const agy = fences.find((f) => /\bagy\b/.test(f) && /gsd-review-antigravity/.test(f));
|
||||
assert.ok(agy, 'review.md should contain the agy invocation bash block');
|
||||
return agy;
|
||||
}
|
||||
|
||||
describe('Antigravity (agy) reviewer invocation in /gsd-review (#2073)', () => {
|
||||
test('review.md exists', () => {
|
||||
assert.ok(fs.existsSync(REVIEW_PATH), 'review.md should exist');
|
||||
});
|
||||
|
||||
test('#2073 mode 1 — does NOT inline the prompt via "$(cat ...)" (arg-list overflow)', () => {
|
||||
const block = agyBashBlock();
|
||||
assert.ok(
|
||||
!/"\$\(cat/.test(block),
|
||||
'agy must not inline the prompt via "$(cat …)" — a large review prompt overflows the exec arg list',
|
||||
);
|
||||
});
|
||||
|
||||
test('#2073 mode 1 — uses a file-reference prompt (mirrors the Cursor block)', () => {
|
||||
const block = agyBashBlock();
|
||||
assert.ok(
|
||||
/Read the file at \/tmp\/gsd-review-prompt-/.test(block),
|
||||
'agy should reference the prompt by file path, not inline it',
|
||||
);
|
||||
});
|
||||
|
||||
test('#2073 mode 3 — pairs agy with an external wall-clock killer when available (timeout/gtimeout probe)', () => {
|
||||
const block = agyBashBlock();
|
||||
// Capability probe for GNU `timeout` and macOS `gtimeout` (stock macOS has neither).
|
||||
assert.match(block, /command -v timeout/, 'agy block should probe for the `timeout` killer');
|
||||
assert.match(block, /command -v gtimeout/, 'agy block should probe for `gtimeout` (macOS Homebrew)');
|
||||
// The external cap (600s) is >= agy's native --print-timeout (540s) so it only
|
||||
// backstops a pre-session stall, never cuts a healthy run.
|
||||
assert.match(block, /600 agy --print-timeout 540s/, 'external cap (600s) must be >= --print-timeout (540s)');
|
||||
// Graceful fallback when no external killer is available (stock macOS).
|
||||
assert.match(block, /else\n\s*agy --print-timeout 540s/,
|
||||
'agy block must fall back to --print-timeout alone when no external killer is available (macOS)');
|
||||
});
|
||||
|
||||
test('#2073 mode 3 — stdin is tied to /dev/null (no tty stall)', () => {
|
||||
const block = agyBashBlock();
|
||||
assert.ok(
|
||||
/<\/dev\/null/.test(block),
|
||||
'agy invocation should redirect stdin from /dev/null so it never blocks on a tty',
|
||||
);
|
||||
});
|
||||
|
||||
test('#2073 mode 2 — wires review.models.agy via --model when configured', () => {
|
||||
const block = agyBashBlock();
|
||||
assert.match(block, /--model "\$AGY_MODEL"/, 'agy block should pass --model "$AGY_MODEL" when set');
|
||||
});
|
||||
|
||||
test('#2073 mode 2 — Step 3 stub surfaces a diagnostic from agy cli.log (not just a generic stub)', () => {
|
||||
const block = agyBashBlock();
|
||||
assert.ok(
|
||||
/cli\.log/.test(block),
|
||||
'the empty-output stub should inspect agy cli.log for a model-availability diagnostic (NOT_FOUND / agent executor error)',
|
||||
);
|
||||
});
|
||||
|
||||
test('#2073 — stale "no --model flag" maintainer note is corrected', () => {
|
||||
const content = fs.readFileSync(REVIEW_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
!/No .{0,4}-m.{0,4}\/.{0,4}--model.{0,4} flag/i.test(content),
|
||||
'the stale maintainer note claiming agy has no --model flag must be corrected (--model exists since ~1.0.3)',
|
||||
);
|
||||
});
|
||||
|
||||
test('#2073 — review.models.agy is documented as supported (not "reserved for future")', () => {
|
||||
const content = fs.readFileSync(REVIEW_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
!/review\.models\.agy is reserved for future/i.test(content),
|
||||
'review.models.agy is now wired (passed as --model); the "reserved for future" comment must be updated',
|
||||
);
|
||||
});
|
||||
});
|
||||
183
tests/claude-imperative-reference.test.cjs
Normal file
183
tests/claude-imperative-reference.test.cjs
Normal file
@@ -0,0 +1,183 @@
|
||||
// allow-test-rule: AC2 requires asserting no `runtime === 'claude'` string-equality branch remains in bin/install.js — the descriptor-migration contract is a property of the source text, so a source-grep is the only faithful check (#2086)
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* claude imperative reference host — ADR-1239 Phase D / #2086 (EoS/claude).
|
||||
*
|
||||
* claude is GSD's tier-1 reference host — "golden parity vs. the Claude reference
|
||||
* host" (ADR-1239 line 146). This proves claude is driven through the PUBLIC
|
||||
* Host-Integration Interface (the imperative adapter), that its negotiated axes
|
||||
* classify + negotiate correctly, that negotiation FAILS CLOSED on a corrupted
|
||||
* descriptor, and that the migration retired the hardcoded `runtime === 'claude'`
|
||||
* string-equality branches in bin/install.js (folded into descriptor-driven
|
||||
* `runtime.hostBehaviors`).
|
||||
*
|
||||
* Mirrors tests/pi-imperative-reference.test.cjs + tests/vscode-ide-reference.test.cjs
|
||||
* but binds against the REAL descriptor + the REAL installer source, since claude
|
||||
* (unlike pi/vscode) has a real production install being folded in.
|
||||
*/
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const { createImperativeAdapter } = require('../gsd-core/bin/lib/adapter-imperative.cjs');
|
||||
const {
|
||||
profileOf,
|
||||
negotiateHostCapabilities,
|
||||
PROTOCOL_VERSION,
|
||||
PROFILE_BASELINES,
|
||||
UNDOCUMENTED,
|
||||
} = require('../gsd-core/bin/lib/host-integration.cjs');
|
||||
|
||||
const CLAUDE_CAP = JSON.parse(
|
||||
fs.readFileSync(path.join(__dirname, '..', 'capabilities', 'claude', 'capability.json'), 'utf8'),
|
||||
);
|
||||
const CLAUDE_AXES = CLAUDE_CAP.runtime.hostIntegration;
|
||||
|
||||
// Requiring the installer (not as main) never runs the CLI; GSD_TEST_MODE is set
|
||||
// defensively to match the install-test convention.
|
||||
process.env.GSD_TEST_MODE = process.env.GSD_TEST_MODE || '1';
|
||||
const installMod = require('../bin/install.js');
|
||||
|
||||
// -- AC2: driven through the public interface (imperative adapter) -----------
|
||||
|
||||
test('createImperativeAdapter classifies claude as imperative + composes the registry', () => {
|
||||
const adapter = createImperativeAdapter({ runtime: 'claude' });
|
||||
assert.equal(adapter.kind, 'imperative', "claude embeddingMode is imperative -> adapter kind must be 'imperative'");
|
||||
assert.equal(adapter.runtime, 'claude');
|
||||
assert.ok(adapter.registry && typeof adapter.registry === 'object', 'imperative adapter exposes the composed capability registry');
|
||||
assert.equal(typeof adapter.install, 'function');
|
||||
assert.equal(typeof adapter.uninstall, 'function');
|
||||
});
|
||||
|
||||
test('claude descriptor embeddingMode agrees with the imperative adapter kind', () => {
|
||||
assert.equal(CLAUDE_AXES.embeddingMode, 'imperative', 'capability.json must declare embeddingMode: imperative for the imperative binding');
|
||||
});
|
||||
|
||||
test('claude axes classify as the programmatic-cli reference profile', () => {
|
||||
assert.equal(profileOf(CLAUDE_AXES), 'programmatic-cli');
|
||||
assert.notEqual(profileOf(CLAUDE_AXES), 'ide');
|
||||
});
|
||||
|
||||
// -- AC3: every negotiated axis populated, negotiation is clean ---------------
|
||||
|
||||
test('claude negotiates its declared axes verbatim (no degradation of documented values)', () => {
|
||||
const result = negotiateHostCapabilities({ ...CLAUDE_AXES, protocolVersion: PROTOCOL_VERSION });
|
||||
assert.equal(result.protocolVersion, PROTOCOL_VERSION);
|
||||
// Every declared scalar axis survives negotiation unchanged (all are known+documented).
|
||||
assert.equal(result.effective.embeddingMode, 'imperative');
|
||||
assert.equal(result.effective.commandSurface, CLAUDE_AXES.commandSurface);
|
||||
assert.equal(result.effective.modelMode, CLAUDE_AXES.modelMode);
|
||||
assert.equal(result.effective.hookBus, CLAUDE_AXES.hookBus);
|
||||
assert.equal(result.effective.stateIO, CLAUDE_AXES.stateIO);
|
||||
assert.equal(result.effective.transport, CLAUDE_AXES.transport);
|
||||
assert.equal(result.effective.runtime, CLAUDE_AXES.runtime);
|
||||
// No `undocumented` sentinel anywhere in claude's declared axes.
|
||||
assert.ok(
|
||||
!JSON.stringify(CLAUDE_AXES).includes(UNDOCUMENTED),
|
||||
'claude descriptor must carry no `undocumented` sentinel (fully doc-sourced)',
|
||||
);
|
||||
});
|
||||
|
||||
// -- AC5: negotiation fails CLOSED on a corrupted / partial descriptor --------
|
||||
|
||||
test('negotiateHostCapabilities never throws for claude — even fully corrupted input', () => {
|
||||
assert.doesNotThrow(() => negotiateHostCapabilities({}));
|
||||
assert.doesNotThrow(() => negotiateHostCapabilities({ embeddingMode: UNDOCUMENTED }));
|
||||
assert.doesNotThrow(() => negotiateHostCapabilities({ embeddingMode: 'wildly-unknown-future-value' }));
|
||||
});
|
||||
|
||||
test('a partial/empty claude descriptor degrades to the safe floor — NOT the full programmatic-cli baseline', () => {
|
||||
const result = negotiateHostCapabilities({});
|
||||
// Fail-closed floor (SAFE_DEFAULTS), not the rich profile baseline.
|
||||
assert.equal(result.effective.embeddingMode, 'declarative', 'omitted embeddingMode degrades closed to declarative');
|
||||
assert.equal(result.effective.hookBus, 'none', 'omitted hookBus degrades closed to none');
|
||||
assert.equal(result.effective.commandSurface, 'prose-only', 'omitted commandSurface degrades closed to prose-only');
|
||||
assert.equal(result.effective.dispatch.namedDispatch, false, 'omitted dispatch degrades closed (no named dispatch)');
|
||||
assert.notDeepEqual(
|
||||
result.effective,
|
||||
PROFILE_BASELINES['programmatic-cli'],
|
||||
'a corrupted descriptor MUST NOT silently reuse the full programmatic-cli baseline',
|
||||
);
|
||||
assert.ok(result.warnings.length > 0, 'degrade-closed must surface warnings');
|
||||
});
|
||||
|
||||
test('an undocumented/unknown claude axis value is not trusted (degraded closed per-axis)', () => {
|
||||
const corrupted = { ...CLAUDE_AXES, embeddingMode: UNDOCUMENTED, hookBus: 'unknown-bus-kind' };
|
||||
const result = negotiateHostCapabilities(corrupted);
|
||||
assert.equal(result.effective.embeddingMode, 'declarative', 'undocumented embeddingMode -> safe floor');
|
||||
assert.equal(result.effective.hookBus, 'none', 'unknown hookBus value -> safe floor');
|
||||
// Untouched axes still negotiate to their declared (documented) values.
|
||||
assert.equal(result.effective.stateIO, CLAUDE_AXES.stateIO);
|
||||
});
|
||||
|
||||
// -- AC2: the hardcoded string-equality branches are retired ------------------
|
||||
|
||||
test('claude descriptor declares runtime.hostBehaviors (the folded-in host behaviors)', () => {
|
||||
const hb = CLAUDE_CAP.runtime.hostBehaviors;
|
||||
assert.ok(hb && typeof hb === 'object', 'capabilities/claude/capability.json must declare runtime.hostBehaviors');
|
||||
// The behaviors that replaced the 13 `runtime === 'claude'` branches.
|
||||
assert.equal(hb.permissionsSchema, 'claude');
|
||||
assert.equal(hb.localInstallStyle, 'legacy-flat');
|
||||
assert.equal(hb.sourceMarkerFile, '.gsd-source');
|
||||
assert.equal(hb.authorsCanonicalWorkflow, true);
|
||||
assert.equal(hb.ownsClaudePaths, true);
|
||||
assert.equal(hb.nativeModelAliases, true);
|
||||
assert.equal(hb.skillsGlobalOnboarding, true);
|
||||
assert.equal(hb.attributionSource, 'settings-json-commit');
|
||||
assert.deepEqual(hb.agentFrontmatterExtensions, ['effort']);
|
||||
assert.equal(hb.settingsFileByScope.local, 'settings.local.json');
|
||||
assert.equal(hb.settingsFileByScope.global, 'settings.json');
|
||||
});
|
||||
|
||||
test('bin/install.js contains no `runtime === "claude"` / `runtime !== "claude"` string-equality branches (AC2)', () => {
|
||||
const src = fs.readFileSync(path.join(__dirname, '..', 'bin', 'install.js'), 'utf8');
|
||||
// Strip comments + backtick/inline-code spans so PROSE mentions of the old
|
||||
// pattern (a comment explaining "not a string-equality branch") do not
|
||||
// false-positive — only LIVE code counts.
|
||||
const codeOnly = src
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '') // block comments
|
||||
.replace(/\/\/[^\r\n]*/g, '') // line comments (CRLF-safe)
|
||||
.replace(/`[^`]*`/g, ''); // backtick / inline-code spans
|
||||
const offenders = codeOnly.match(/runtime\s*[!=]==\s*'claude'/g) || [];
|
||||
assert.deepEqual(
|
||||
offenders,
|
||||
[],
|
||||
`AC2: every hardcoded runtime==='claude'/!=='claude' branch must be descriptor-driven; found: ${offenders.join(', ')}`,
|
||||
);
|
||||
});
|
||||
|
||||
// -- Reviewer #2106 (elevated): #338 privacy fail-safe on registry-load failure --
|
||||
// If the first-party capability registry fails to load, `_hostBehaviors('claude')`
|
||||
// would return {} and route a claude LOCAL install to the repo-shared settings.json
|
||||
// instead of the gitignored settings.local.json — silently reintroducing #338. The
|
||||
// reference host must degrade CLOSED (safe) for its privacy-critical keys.
|
||||
|
||||
test('claude #338-critical host behaviors degrade CLOSED when the capability registry cannot load', () => {
|
||||
// Simulate a broken bundle: registry is undefined.
|
||||
const degraded = installMod._resolveHostBehaviors('claude', undefined);
|
||||
assert.equal(degraded.settingsFileByScope.local, 'settings.local.json',
|
||||
'#338: a claude LOCAL install must still route to the gitignored settings.local.json');
|
||||
assert.equal(degraded.settingsFileByScope.global, 'settings.json');
|
||||
assert.equal(degraded.permissionsSchema, 'claude', 'permission cleanup/merge must still apply');
|
||||
assert.equal(degraded.sourceMarkerFile, '.gsd-source');
|
||||
});
|
||||
|
||||
test('with the registry present, claude host behaviors come from the live descriptor (superset of the fail-safe floor)', () => {
|
||||
const reg = require('../gsd-core/bin/lib/capability-registry.cjs');
|
||||
const declared = installMod._resolveHostBehaviors('claude', reg);
|
||||
assert.equal(declared.settingsFileByScope.local, 'settings.local.json');
|
||||
assert.equal(declared.localInstallStyle, 'legacy-flat');
|
||||
assert.equal(declared.authorsCanonicalWorkflow, true);
|
||||
// The fail-safe floor is a strict subset of what the descriptor declares.
|
||||
for (const k of Object.keys(installMod.FALLBACK_HOST_BEHAVIORS.claude)) {
|
||||
assert.ok(k in declared, `descriptor must still declare the #338-critical key '${k}'`);
|
||||
}
|
||||
});
|
||||
|
||||
test('a non-reference runtime has no fail-safe fallback (degrades to the generic path)', () => {
|
||||
assert.deepEqual(installMod._resolveHostBehaviors('opencode', undefined), {});
|
||||
assert.deepEqual(installMod._resolveHostBehaviors('codex', undefined), {});
|
||||
});
|
||||
@@ -60,21 +60,34 @@ const { resolveInstallPlan } = require('../gsd-core/bin/lib/runtime-config-adapt
|
||||
|
||||
function runCodexInstall(codexHome, cwd = path.join(__dirname, '..')) {
|
||||
const previousCodeHome = process.env.CODEX_HOME;
|
||||
const previousHome = process.env.HOME;
|
||||
const previousUserProfile = process.env.USERPROFILE;
|
||||
const previousCwd = process.cwd();
|
||||
process.env.CODEX_HOME = codexHome;
|
||||
// #2088: Codex skills now install to the canonical $HOME/.agents/skills root
|
||||
// (os.homedir()-relative, independent of CODEX_HOME — per codex core-skills
|
||||
// loader.rs). Sandbox HOME to codexHome so skills land under the temp dir
|
||||
// (codexHome/.agents/skills) instead of polluting the developer's real home.
|
||||
process.env.HOME = codexHome;
|
||||
process.env.USERPROFILE = codexHome;
|
||||
|
||||
try {
|
||||
process.chdir(cwd);
|
||||
return install(true, 'codex');
|
||||
} finally {
|
||||
process.chdir(previousCwd);
|
||||
if (previousCodeHome === undefined) {
|
||||
delete process.env.CODEX_HOME;
|
||||
} else {
|
||||
process.env.CODEX_HOME = previousCodeHome;
|
||||
}
|
||||
if (previousCodeHome === undefined) delete process.env.CODEX_HOME;
|
||||
else process.env.CODEX_HOME = previousCodeHome;
|
||||
if (previousHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = previousHome;
|
||||
if (previousUserProfile === undefined) delete process.env.USERPROFILE;
|
||||
else process.env.USERPROFILE = previousUserProfile;
|
||||
}
|
||||
}
|
||||
// #2088: the canonical Codex skill-install root, sandboxed under codexHome.
|
||||
function codexSkillsRoot(codexHome) {
|
||||
return path.join(codexHome, '.agents', 'skills');
|
||||
}
|
||||
|
||||
function readCodexConfig(codexHome) {
|
||||
return fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8');
|
||||
@@ -722,17 +735,19 @@ describe('generateCodexConfigBlock', () => {
|
||||
assert.ok(result.startsWith(GSD_CODEX_MARKER), 'starts with marker');
|
||||
});
|
||||
|
||||
test('does not include feature flags or agents table header', () => {
|
||||
test('emits the [agents] max_depth tuning block but no feature flags (#2088)', () => {
|
||||
const result = generateCodexConfigBlock(agents);
|
||||
assert.ok(!result.includes('[features]'), 'no features table');
|
||||
assert.ok(!result.includes('multi_agent'), 'no multi_agent');
|
||||
assert.ok(!result.includes('default_mode_request_user_input'), 'no request_user_input');
|
||||
// Should not have bare [agents] table header (only [agents.<name>] structs).
|
||||
assert.ok(!result.match(/^\[agents\]\s*$/m), 'no bare [agents] table');
|
||||
// #2088: the managed block DOES pin dispatch depth via a bare [agents]
|
||||
// AgentsToml scalar table (coexisting with the [agents.<name>] role structs).
|
||||
assert.match(result, /^\[agents\]$/m, 'emits the [agents] tuning table');
|
||||
assert.match(result, /^max_depth = 1$/m, 'pins max_depth = 1');
|
||||
// Should not emit [[agents]] sequence format (rejected by Codex 0.124.0).
|
||||
assert.ok(!result.includes('[[agents]]'), 'no [[agents]] sequence format');
|
||||
assert.ok(!result.includes('max_threads'), 'no max_threads');
|
||||
assert.ok(!result.includes('max_depth'), 'no max_depth');
|
||||
// Only max_depth is managed — max_threads is intentionally left to the user.
|
||||
assert.ok(!result.includes('max_threads'), 'no max_threads (only max_depth is GSD-managed)');
|
||||
});
|
||||
|
||||
test('#2727: emits [agents.<name>] struct format (Codex 0.120.0+, replaces #2645 [[agents]])', () => {
|
||||
@@ -2721,7 +2736,7 @@ describe('cleanupCodexSkillMetadataSidecars (#1326)', () => {
|
||||
const codexHome = path.join(tmpDir, 'codex-home');
|
||||
fs.mkdirSync(codexHome, { recursive: true });
|
||||
runCodexInstall(codexHome);
|
||||
const skillsDir = path.join(codexHome, 'skills');
|
||||
const skillsDir = codexSkillsRoot(codexHome);
|
||||
assert.ok(fs.existsSync(skillsDir), 'Codex install must create a skills/ directory');
|
||||
const gsdSkillDirs = fs.readdirSync(skillsDir, { withFileTypes: true })
|
||||
.filter(e => e.isDirectory() && e.name.startsWith('gsd-') && e.name !== 'gsd-dev-preferences');
|
||||
@@ -2798,12 +2813,28 @@ before(() => {
|
||||
|
||||
describe('#2698: CRLF stale gsd-update-check block is removed on Codex reinstall', () => {
|
||||
let tmpDir;
|
||||
let _previousHome;
|
||||
let _previousUserProfile;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-crlf-install-2698-'));
|
||||
// #2088 (ADR-1239 upgrade 3): Codex's skills-kind `home: ".agents"` override
|
||||
// applies to BOTH global and local scope and resolves via os.homedir(). This
|
||||
// describe block calls install(false, 'codex') (local scope) directly —
|
||||
// without sandboxing HOME/USERPROFILE to tmpDir, that in-process install
|
||||
// would materialize a full gsd-* skill set into the developer/CI machine's
|
||||
// REAL $HOME/.agents/skills instead of the temp dir.
|
||||
_previousHome = process.env.HOME;
|
||||
_previousUserProfile = process.env.USERPROFILE;
|
||||
process.env.HOME = tmpDir;
|
||||
process.env.USERPROFILE = tmpDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (_previousHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = _previousHome;
|
||||
if (_previousUserProfile === undefined) delete process.env.USERPROFILE;
|
||||
else process.env.USERPROFILE = _previousUserProfile;
|
||||
// Use the shared 5s Windows-EBUSY retry budget instead of inline 1s.
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
@@ -2993,7 +3024,16 @@ if (previousGsdTestMode === undefined) {
|
||||
function runCodexInstall(codexHome, cwd = path.join(__dirname, '..')) {
|
||||
const previousCodeHome = process.env.CODEX_HOME;
|
||||
const previousCwd = process.cwd();
|
||||
// #2088 (ADR-1239 upgrade 3): Codex skills now install to the canonical
|
||||
// $HOME/.agents/skills root (os.homedir()-relative, independent of
|
||||
// CODEX_HOME). Sandbox HOME (and USERPROFILE) to codexHome so this
|
||||
// in-process install never materializes skills under the developer/CI
|
||||
// machine's real home directory.
|
||||
const previousHome = process.env.HOME;
|
||||
const previousUserProfile = process.env.USERPROFILE;
|
||||
process.env.CODEX_HOME = codexHome;
|
||||
process.env.HOME = codexHome;
|
||||
process.env.USERPROFILE = codexHome;
|
||||
try {
|
||||
process.chdir(cwd);
|
||||
return install(true, 'codex');
|
||||
@@ -3004,6 +3044,10 @@ function runCodexInstall(codexHome, cwd = path.join(__dirname, '..')) {
|
||||
} else {
|
||||
process.env.CODEX_HOME = previousCodeHome;
|
||||
}
|
||||
if (previousHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = previousHome;
|
||||
if (previousUserProfile === undefined) delete process.env.USERPROFILE;
|
||||
else process.env.USERPROFILE = previousUserProfile;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4583,7 +4627,16 @@ before(() => {
|
||||
function runCodexInstall(codexHome) {
|
||||
const previousCodexHome = process.env.CODEX_HOME;
|
||||
const previousCwd = process.cwd();
|
||||
// #2088 (ADR-1239 upgrade 3): Codex skills now install to the canonical
|
||||
// $HOME/.agents/skills root (os.homedir()-relative, independent of
|
||||
// CODEX_HOME). Sandbox HOME (and USERPROFILE) to codexHome so this
|
||||
// in-process install never materializes skills under the developer/CI
|
||||
// machine's real home directory.
|
||||
const previousHome = process.env.HOME;
|
||||
const previousUserProfile = process.env.USERPROFILE;
|
||||
process.env.CODEX_HOME = codexHome;
|
||||
process.env.HOME = codexHome;
|
||||
process.env.USERPROFILE = codexHome;
|
||||
try {
|
||||
process.chdir(path.join(__dirname, '..'));
|
||||
return install(true, 'codex');
|
||||
@@ -4594,6 +4647,10 @@ function runCodexInstall(codexHome) {
|
||||
} else {
|
||||
process.env.CODEX_HOME = previousCodexHome;
|
||||
}
|
||||
if (previousHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = previousHome;
|
||||
if (previousUserProfile === undefined) delete process.env.USERPROFILE;
|
||||
else process.env.USERPROFILE = previousUserProfile;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4908,7 +4965,7 @@ describe('#3245 — idempotent rollback reverts skills/, agents/, and VERSION',
|
||||
assert.strictEqual(threw, true, 'install must throw when validation fails');
|
||||
|
||||
// skills/ — GSD writes gsd-* subdirs here. All must be absent after rollback.
|
||||
const skillsDir = path.join(codexHome, 'skills');
|
||||
const skillsDir = codexSkillsRoot(codexHome);
|
||||
if (fs.existsSync(skillsDir)) {
|
||||
const gsdSkills = fs.readdirSync(skillsDir, { withFileTypes: true })
|
||||
.filter(e => e.isDirectory() && e.name.startsWith('gsd-'));
|
||||
@@ -4963,7 +5020,7 @@ describe('#3245 — idempotent rollback reverts skills/, agents/, and VERSION',
|
||||
assert.strictEqual(threw, true, 'install must throw when validation fails (very early failure)');
|
||||
// Rollback removes all gsd-* skill dirs it wrote. Even if skills/ was
|
||||
// created during the install, no gsd-* dirs should survive after rollback.
|
||||
const skillsDir = path.join(codexHome, 'skills');
|
||||
const skillsDir = codexSkillsRoot(codexHome);
|
||||
const remainingGsdSkills = fs.existsSync(skillsDir)
|
||||
? fs.readdirSync(skillsDir, { withFileTypes: true })
|
||||
.filter((e) => e.isDirectory() && e.name.startsWith('gsd-'))
|
||||
@@ -4978,7 +5035,7 @@ describe('#3245 — idempotent rollback reverts skills/, agents/, and VERSION',
|
||||
|
||||
test('rollback does not remove pre-existing user skills that GSD did not write', () => {
|
||||
// If the user has a custom skill dir (not gsd-*) it must survive rollback.
|
||||
const skillsDir = path.join(codexHome, 'skills');
|
||||
const skillsDir = codexSkillsRoot(codexHome);
|
||||
const userSkill = path.join(skillsDir, 'my-custom-skill');
|
||||
fs.mkdirSync(userSkill, { recursive: true });
|
||||
fs.writeFileSync(path.join(userSkill, 'SKILL.md'), '# Custom\n', 'utf8');
|
||||
@@ -5248,7 +5305,16 @@ describe('#3285 — install succeeds when config.toml contains hooks.state entri
|
||||
function runCodexInstall() {
|
||||
const previousCodexHome = process.env.CODEX_HOME;
|
||||
const previousCwd = process.cwd();
|
||||
// #2088 (ADR-1239 upgrade 3): Codex skills now install to the canonical
|
||||
// $HOME/.agents/skills root (os.homedir()-relative, independent of
|
||||
// CODEX_HOME). Sandbox HOME (and USERPROFILE) to tmpDir so this
|
||||
// in-process install never materializes skills under the developer/CI
|
||||
// machine's real home directory.
|
||||
const previousHome = process.env.HOME;
|
||||
const previousUserProfile = process.env.USERPROFILE;
|
||||
process.env.CODEX_HOME = codexHome;
|
||||
process.env.HOME = tmpDir;
|
||||
process.env.USERPROFILE = tmpDir;
|
||||
try {
|
||||
process.chdir(path.join(__dirname, '..'));
|
||||
return install(true, 'codex');
|
||||
@@ -5259,6 +5325,10 @@ describe('#3285 — install succeeds when config.toml contains hooks.state entri
|
||||
} else {
|
||||
process.env.CODEX_HOME = previousCodexHome;
|
||||
}
|
||||
if (previousHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = previousHome;
|
||||
if (previousUserProfile === undefined) delete process.env.USERPROFILE;
|
||||
else process.env.USERPROFILE = previousUserProfile;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5930,11 +6000,24 @@ describe('#3426 uninstall: gsd-check-update.cmd is removed from hooks dir on uni
|
||||
|
||||
function withCodexHome(dir, fn) {
|
||||
const prev = process.env.CODEX_HOME;
|
||||
// #2088 (ADR-1239 upgrade 3): Codex skills now resolve an alternate install
|
||||
// home rooted at the REAL os.homedir() ($HOME/.agents), independent of
|
||||
// CODEX_HOME. Fake $HOME (and $USERPROFILE) too so this in-process install
|
||||
// never touches the developer/CI machine's real home directory — confined
|
||||
// entirely to `dir`, which the caller cleans up.
|
||||
const prevHome = process.env.HOME;
|
||||
const prevUserProfile = process.env.USERPROFILE;
|
||||
process.env.CODEX_HOME = dir;
|
||||
process.env.HOME = dir;
|
||||
process.env.USERPROFILE = dir;
|
||||
try { return fn(); }
|
||||
finally {
|
||||
if (prev == null) delete process.env.CODEX_HOME;
|
||||
else process.env.CODEX_HOME = prev;
|
||||
if (prevHome == null) delete process.env.HOME;
|
||||
else process.env.HOME = prevHome;
|
||||
if (prevUserProfile == null) delete process.env.USERPROFILE;
|
||||
else process.env.USERPROFILE = prevUserProfile;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6050,12 +6133,27 @@ const BUILD_HOOKS_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js
|
||||
|
||||
function withCodexHome(codexHome, fn) {
|
||||
const prev = process.env.CODEX_HOME;
|
||||
// #2088 (ADR-1239 upgrade 3): Codex skills now resolve an alternate install
|
||||
// home rooted at the REAL os.homedir() ($HOME/.agents), independent of
|
||||
// CODEX_HOME. Fake $HOME (and $USERPROFILE) too — using the sandbox root
|
||||
// (codexHome's parent, since codexHome is conventionally `<tmpRoot>/.codex`
|
||||
// in this file) — so this in-process install never touches the developer/CI
|
||||
// machine's real home directory. tmpRoot is reclaimed by the caller's afterEach.
|
||||
const prevHome = process.env.HOME;
|
||||
const prevUserProfile = process.env.USERPROFILE;
|
||||
const fakeHome = path.dirname(codexHome);
|
||||
process.env.CODEX_HOME = codexHome;
|
||||
process.env.HOME = fakeHome;
|
||||
process.env.USERPROFILE = fakeHome;
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
if (prev == null) delete process.env.CODEX_HOME;
|
||||
else process.env.CODEX_HOME = prev;
|
||||
if (prevHome == null) delete process.env.HOME;
|
||||
else process.env.HOME = prevHome;
|
||||
if (prevUserProfile == null) delete process.env.USERPROFILE;
|
||||
else process.env.USERPROFILE = prevUserProfile;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6107,22 +6205,32 @@ describe('#3427 + #3433 — Codex installer avoids duplicate skills and mixed ho
|
||||
|
||||
withCodexHome(codexHome, () => install(true, 'codex'));
|
||||
|
||||
const skillsDir = path.join(codexHome, 'skills');
|
||||
const entries = fs.existsSync(skillsDir)
|
||||
? fs.readdirSync(skillsDir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name)
|
||||
// #2088: the managed gsd-* skill surface now regenerates at the
|
||||
// canonical $HOME/.agents/skills root (fakeHome === tmpRoot here — see
|
||||
// withCodexHome above), not under the legacy $CODEX_HOME/skills.
|
||||
const newSkillsDir = codexSkillsRoot(tmpRoot);
|
||||
const newEntries = fs.existsSync(newSkillsDir)
|
||||
? fs.readdirSync(newSkillsDir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name)
|
||||
: [];
|
||||
|
||||
// #3562: $gsd-* commands are discoverable only when skills/gsd-*/SKILL.md
|
||||
// exists. The installer must regenerate (not remove) the managed gsd-*
|
||||
// directories.
|
||||
assert.equal(entries.includes('gsd-help'), true);
|
||||
const refreshedBody = fs.readFileSync(path.join(skillsDir, 'gsd-help', 'SKILL.md'), 'utf8');
|
||||
// #3562: $gsd-* commands are discoverable only when
|
||||
// .agents/skills/gsd-*/SKILL.md exists. The installer must regenerate
|
||||
// (not remove) the managed gsd-* directories.
|
||||
assert.equal(newEntries.includes('gsd-help'), true);
|
||||
const refreshedBody = fs.readFileSync(path.join(newSkillsDir, 'gsd-help', 'SKILL.md'), 'utf8');
|
||||
assert.notEqual(refreshedBody, legacySkillBody, 'stale legacy body must be overwritten');
|
||||
const frontmatter = parseFrontmatter(refreshedBody);
|
||||
assert.equal(frontmatter.name, 'gsd-help', 'refreshed SKILL.md frontmatter must declare name: gsd-help');
|
||||
|
||||
// Unrelated user skills are preserved — the regen scope is `gsd-*` only.
|
||||
assert.equal(entries.includes('custom-user-skill'), true);
|
||||
// #2088 migration: the installer cleans stale gsd-* dirs out of the old
|
||||
// $CODEX_HOME/skills location on a pre-move install.
|
||||
const legacyHelpDir = path.join(codexHome, 'skills', 'gsd-help');
|
||||
assert.equal(fs.existsSync(legacyHelpDir), false, 'migration must remove the stale legacy gsd-help skill dir from $CODEX_HOME/skills');
|
||||
|
||||
// Unrelated user skills are preserved in place — migration only removes
|
||||
// `gsd-*` dirs from the old location; non-gsd-* user dirs are untouched.
|
||||
const userSkill = path.join(codexHome, 'skills', 'custom-user-skill', 'SKILL.md');
|
||||
assert.equal(fs.existsSync(userSkill), true, 'unrelated user skill must survive the #2088 migration');
|
||||
});
|
||||
|
||||
test('stores managed SessionStart update hook in hooks.json and removes inline gsd hook from config.toml', () => {
|
||||
@@ -6239,12 +6347,27 @@ const BUILD_HOOKS_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js
|
||||
|
||||
function withCodexHome(codexHome, fn) {
|
||||
const prev = process.env.CODEX_HOME;
|
||||
// #2088 (ADR-1239 upgrade 3): Codex skills now resolve an alternate install
|
||||
// home rooted at the REAL os.homedir() ($HOME/.agents), independent of
|
||||
// CODEX_HOME. Fake $HOME (and $USERPROFILE) too — using the sandbox root
|
||||
// (codexHome's parent, since codexHome is conventionally `<tmpRoot>/.codex`
|
||||
// in this file) — so this in-process install never touches the developer/CI
|
||||
// machine's real home directory. tmpRoot is reclaimed by the caller's afterEach.
|
||||
const prevHome = process.env.HOME;
|
||||
const prevUserProfile = process.env.USERPROFILE;
|
||||
const fakeHome = path.dirname(codexHome);
|
||||
process.env.CODEX_HOME = codexHome;
|
||||
process.env.HOME = fakeHome;
|
||||
process.env.USERPROFILE = fakeHome;
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
if (prev == null) delete process.env.CODEX_HOME;
|
||||
else process.env.CODEX_HOME = prev;
|
||||
if (prevHome == null) delete process.env.HOME;
|
||||
else process.env.HOME = prevHome;
|
||||
if (prevUserProfile == null) delete process.env.USERPROFILE;
|
||||
else process.env.USERPROFILE = prevUserProfile;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6268,7 +6391,9 @@ describe('#3562 — Codex install produces discoverable $gsd-* skill surface', {
|
||||
test('global install creates skills/gsd-help/SKILL.md', () => {
|
||||
withCodexHome(codexHome, () => install(true, 'codex'));
|
||||
|
||||
const skillPath = path.join(codexHome, 'skills', 'gsd-help', 'SKILL.md');
|
||||
// #2088: skills now install to the canonical $HOME/.agents/skills root.
|
||||
// withCodexHome fakes $HOME to tmpRoot (codexHome's parent) above.
|
||||
const skillPath = path.join(codexSkillsRoot(tmpRoot), 'gsd-help', 'SKILL.md');
|
||||
assert.ok(
|
||||
fs.existsSync(skillPath),
|
||||
`Codex install must create ${skillPath} so $gsd-help is discoverable. ` +
|
||||
@@ -6279,7 +6404,7 @@ describe('#3562 — Codex install produces discoverable $gsd-* skill surface', {
|
||||
test('SKILL.md content has frontmatter expected by Codex skill discovery', () => {
|
||||
withCodexHome(codexHome, () => install(true, 'codex'));
|
||||
|
||||
const skillPath = path.join(codexHome, 'skills', 'gsd-help', 'SKILL.md');
|
||||
const skillPath = path.join(codexSkillsRoot(tmpRoot), 'gsd-help', 'SKILL.md');
|
||||
assert.ok(fs.existsSync(skillPath), 'precondition: SKILL.md exists');
|
||||
|
||||
const content = fs.readFileSync(skillPath, 'utf8');
|
||||
@@ -6290,7 +6415,7 @@ describe('#3562 — Codex install produces discoverable $gsd-* skill surface', {
|
||||
test('multiple core $gsd-* skills are produced (not just gsd-help)', () => {
|
||||
withCodexHome(codexHome, () => install(true, 'codex'));
|
||||
|
||||
const skillsDir = path.join(codexHome, 'skills');
|
||||
const skillsDir = codexSkillsRoot(tmpRoot);
|
||||
assert.ok(fs.existsSync(skillsDir), 'skills/ directory must exist after install');
|
||||
|
||||
const gsdSkills = fs
|
||||
@@ -6384,12 +6509,27 @@ const BUILD_HOOKS_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js
|
||||
|
||||
function withCodexHome(codexHome, fn) {
|
||||
const prev = process.env.CODEX_HOME;
|
||||
// #2088 (ADR-1239 upgrade 3): Codex skills now resolve an alternate install
|
||||
// home rooted at the REAL os.homedir() ($HOME/.agents), independent of
|
||||
// CODEX_HOME. Fake $HOME (and $USERPROFILE) too — using the sandbox root
|
||||
// (codexHome's parent, since codexHome is conventionally `<tmpRoot>/.codex`
|
||||
// in this file) — so this in-process install never touches the developer/CI
|
||||
// machine's real home directory. tmpRoot is reclaimed by the caller's afterEach.
|
||||
const prevHome = process.env.HOME;
|
||||
const prevUserProfile = process.env.USERPROFILE;
|
||||
const fakeHome = path.dirname(codexHome);
|
||||
process.env.CODEX_HOME = codexHome;
|
||||
process.env.HOME = fakeHome;
|
||||
process.env.USERPROFILE = fakeHome;
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
if (prev == null) delete process.env.CODEX_HOME;
|
||||
else process.env.CODEX_HOME = prev;
|
||||
if (prevHome == null) delete process.env.HOME;
|
||||
else process.env.HOME = prevHome;
|
||||
if (prevUserProfile == null) delete process.env.USERPROFILE;
|
||||
else process.env.USERPROFILE = prevUserProfile;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6659,7 +6799,16 @@ function runCodexInstallCaptured() {
|
||||
|
||||
const previousCodexHome = process.env.CODEX_HOME;
|
||||
const previousCwd = process.cwd();
|
||||
// #2088 (ADR-1239 upgrade 3): Codex skills now resolve an alternate install
|
||||
// home rooted at os.homedir() ($HOME/.agents), independent of CODEX_HOME.
|
||||
// Sandbox $HOME (and $USERPROFILE) to codexHome too — otherwise this
|
||||
// in-process install would materialize skills under the developer/CI
|
||||
// machine's REAL home directory instead of the temp dir.
|
||||
const previousHome = process.env.HOME;
|
||||
const previousUserProfile = process.env.USERPROFILE;
|
||||
process.env.CODEX_HOME = codexHome;
|
||||
process.env.HOME = codexHome;
|
||||
process.env.USERPROFILE = codexHome;
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
try {
|
||||
process.chdir(ROOT);
|
||||
@@ -6679,6 +6828,16 @@ function runCodexInstallCaptured() {
|
||||
} else {
|
||||
process.env.CODEX_HOME = previousCodexHome;
|
||||
}
|
||||
if (previousHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = previousHome;
|
||||
}
|
||||
if (previousUserProfile === undefined) {
|
||||
delete process.env.USERPROFILE;
|
||||
} else {
|
||||
process.env.USERPROFILE = previousUserProfile;
|
||||
}
|
||||
if (previousGsdTestMode === undefined) {
|
||||
delete process.env.GSD_TEST_MODE;
|
||||
} else {
|
||||
@@ -6703,7 +6862,7 @@ describe('bug-3582: Codex global install materializes the skill surface', { conc
|
||||
});
|
||||
|
||||
test('writes the exact expected set of gsd-*/SKILL.md skills (deepEqual on name set)', () => {
|
||||
const skillsDir = path.join(installRun.codexHome, 'skills');
|
||||
const skillsDir = codexSkillsRoot(installRun.codexHome);
|
||||
assert.ok(
|
||||
fs.existsSync(skillsDir),
|
||||
`Codex install must create ${skillsDir} (the 1.42.2 regression skipped this entirely)`,
|
||||
@@ -6735,7 +6894,7 @@ describe('bug-3582: Codex global install materializes the skill surface', { conc
|
||||
});
|
||||
|
||||
test('SKILL.md frontmatter declares hyphen-form name matching the directory', () => {
|
||||
const skillsDir = path.join(installRun.codexHome, 'skills');
|
||||
const skillsDir = codexSkillsRoot(installRun.codexHome);
|
||||
const skillDirs = fs.readdirSync(skillsDir, { withFileTypes: true })
|
||||
.filter(e => e.isDirectory() && e.name.startsWith('gsd-'))
|
||||
.map(e => e.name);
|
||||
@@ -6767,7 +6926,7 @@ describe('bug-3582: Codex global install materializes the skill surface', { conc
|
||||
// review); the file on disk must contain its full output verbatim
|
||||
// (open tag, body, closing `</codex_skill_adapter>`). A truncated,
|
||||
// empty, or missing-closing-tag adapter cannot satisfy this assertion.
|
||||
const skillsDir = path.join(installRun.codexHome, 'skills');
|
||||
const skillsDir = codexSkillsRoot(installRun.codexHome);
|
||||
const skillDirs = fs.readdirSync(skillsDir, { withFileTypes: true })
|
||||
.filter(e => e.isDirectory() && e.name.startsWith('gsd-'))
|
||||
.map(e => e.name);
|
||||
@@ -6807,7 +6966,7 @@ describe('bug-3582: Codex global install materializes the skill surface', { conc
|
||||
'gsd-new-project',
|
||||
'gsd-health',
|
||||
];
|
||||
const skillsDir = path.join(installRun.codexHome, 'skills');
|
||||
const skillsDir = codexSkillsRoot(installRun.codexHome);
|
||||
for (const name of representative) {
|
||||
const skillMd = path.join(skillsDir, name, 'SKILL.md');
|
||||
assert.ok(
|
||||
@@ -6818,7 +6977,7 @@ describe('bug-3582: Codex global install materializes the skill surface', { conc
|
||||
});
|
||||
|
||||
test('installed Codex skills do not ask agents to run bare gsd-tools commands', () => {
|
||||
const skillsDir = path.join(installRun.codexHome, 'skills');
|
||||
const skillsDir = codexSkillsRoot(installRun.codexHome);
|
||||
const skillDirs = fs.readdirSync(skillsDir, { withFileTypes: true })
|
||||
.filter(e => e.isDirectory() && e.name.startsWith('gsd-'))
|
||||
.map(e => e.name);
|
||||
@@ -7075,12 +7234,27 @@ const BUILD_HOOKS_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js
|
||||
|
||||
function withCodexHome(codexHome, fn) {
|
||||
const prev = process.env.CODEX_HOME;
|
||||
// #2088 (ADR-1239 upgrade 3): Codex skills now resolve an alternate install
|
||||
// home rooted at the REAL os.homedir() ($HOME/.agents), independent of
|
||||
// CODEX_HOME. Fake $HOME (and $USERPROFILE) too — using the sandbox root
|
||||
// (codexHome's parent, since codexHome is conventionally `<tmpRoot>/.codex`
|
||||
// in this file) — so this in-process install never touches the developer/CI
|
||||
// machine's real home directory. tmpRoot is reclaimed by the caller's afterEach.
|
||||
const prevHome = process.env.HOME;
|
||||
const prevUserProfile = process.env.USERPROFILE;
|
||||
const fakeHome = path.dirname(codexHome);
|
||||
process.env.CODEX_HOME = codexHome;
|
||||
process.env.HOME = fakeHome;
|
||||
process.env.USERPROFILE = fakeHome;
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
if (prev == null) delete process.env.CODEX_HOME;
|
||||
else process.env.CODEX_HOME = prev;
|
||||
if (prevHome == null) delete process.env.HOME;
|
||||
else process.env.HOME = prevHome;
|
||||
if (prevUserProfile == null) delete process.env.USERPROFILE;
|
||||
else process.env.USERPROFILE = prevUserProfile;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
289
tests/codex-declarative-reference.test.cjs
Normal file
289
tests/codex-declarative-reference.test.cjs
Normal file
@@ -0,0 +1,289 @@
|
||||
// allow-test-rule: AC2 requires asserting no `runtime === 'codex'` string-equality and no positive `isCodex` branch remain in bin/install.js/src — the descriptor-migration contract is a property of the source text, so a source-grep is the only faithful check (#2088)
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* codex declarative reference host — ADR-1239 Phase D / #2088 (EoS/codex).
|
||||
*
|
||||
* Proves Codex is driven through the PUBLIC Host-Integration Interface (the
|
||||
* declarative embedding mode), that its negotiated axes classify + negotiate
|
||||
* correctly (including the documented `maxDepth === 1 → flat` dispatch
|
||||
* degradation), that negotiation fails CLOSED on a corrupted descriptor, that
|
||||
* the three Context7-verified UPGRADES land on the user-reachable surface
|
||||
* (skill-root → $HOME/.agents/skills, the 6 new hooks.json lifecycle events, and
|
||||
* explicit `[agents] max_depth` dispatch tuning), and that the migration retired
|
||||
* the hardcoded `runtime === 'codex'` / positive-`isCodex` projection (folded
|
||||
* into descriptor-driven `runtime.hostBehaviors`).
|
||||
*/
|
||||
|
||||
const { 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');
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { createImperativeAdapter } = require('../gsd-core/bin/lib/adapter-imperative.cjs');
|
||||
const { createDeclarativeAdapter } = require('../gsd-core/bin/lib/adapter-declarative.cjs');
|
||||
const {
|
||||
profileOf,
|
||||
negotiateHostCapabilities,
|
||||
degradationFor,
|
||||
PROFILE_BASELINES,
|
||||
UNDOCUMENTED,
|
||||
} = require('../gsd-core/bin/lib/host-integration.cjs');
|
||||
|
||||
const install = require('../bin/install.js');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const CODEX_CAP = JSON.parse(
|
||||
fs.readFileSync(path.join(__dirname, '..', 'capabilities', 'codex', 'capability.json'), 'utf8'),
|
||||
);
|
||||
const CODEX_AXES = CODEX_CAP.runtime.hostIntegration;
|
||||
|
||||
// -- AC2: driven through the public interface (declarative embedding mode) ----
|
||||
|
||||
test('codex axes classify as the declarative-cli reference profile', () => {
|
||||
assert.equal(profileOf(CODEX_AXES), 'declarative-cli');
|
||||
});
|
||||
|
||||
test('createDeclarativeAdapter classifies codex as declarative + delegates install to the engine', () => {
|
||||
const adapter = createDeclarativeAdapter({ runtime: 'codex' });
|
||||
assert.equal(adapter.kind, 'declarative');
|
||||
assert.equal(adapter.runtime, 'codex');
|
||||
assert.equal(typeof adapter.install, 'function');
|
||||
assert.equal(typeof adapter.uninstall, 'function');
|
||||
});
|
||||
|
||||
test('the composed-registry install adapter drives codex install/uninstall', () => {
|
||||
const adapter = createImperativeAdapter({ runtime: 'codex' });
|
||||
assert.equal(adapter.kind, 'imperative');
|
||||
assert.equal(adapter.runtime, 'codex');
|
||||
assert.ok(adapter.registry && typeof adapter.registry === 'object');
|
||||
assert.equal(typeof adapter.install, 'function');
|
||||
assert.equal(typeof adapter.uninstall, 'function');
|
||||
});
|
||||
|
||||
// -- AC3: every negotiated axis populated (no undocumented sentinel) ----------
|
||||
|
||||
test('every codex hostIntegration axis is populated (zero undocumented sentinels)', () => {
|
||||
const axisVals = [
|
||||
CODEX_AXES.embeddingMode, CODEX_AXES.commandSurface, CODEX_AXES.modelMode,
|
||||
CODEX_AXES.hookBus, CODEX_AXES.stateIO, CODEX_AXES.transport, CODEX_AXES.runtime,
|
||||
];
|
||||
for (const v of axisVals) {
|
||||
assert.notEqual(v, UNDOCUMENTED, `axis must be documented, got ${v}`);
|
||||
assert.ok(typeof v === 'string' && v.length > 0);
|
||||
}
|
||||
const d = CODEX_AXES.dispatch;
|
||||
for (const k of ['namedDispatch', 'nested', 'maxDepth', 'subagentToolkit', 'background', 'backgroundDispatch']) {
|
||||
assert.notEqual(d[k], UNDOCUMENTED, `dispatch.${k} must be documented`);
|
||||
assert.notEqual(d[k], undefined, `dispatch.${k} must be present`);
|
||||
}
|
||||
assert.equal(d.embeddingMode, undefined); // sanity: dispatch has no stray keys
|
||||
});
|
||||
|
||||
// -- AC5: dispatch degrades to flat because maxDepth === 1 -------------------
|
||||
|
||||
test('dispatch degrades to FLAT for codex — maxDepth===1 even though nested/background are all true', () => {
|
||||
assert.equal(CODEX_AXES.dispatch.nested, true);
|
||||
assert.equal(CODEX_AXES.dispatch.background, true);
|
||||
assert.equal(CODEX_AXES.dispatch.backgroundDispatch, true);
|
||||
assert.equal(CODEX_AXES.dispatch.maxDepth, 1);
|
||||
|
||||
const flat = degradationFor('dispatch', CODEX_AXES);
|
||||
assert.equal(flat.level, 'degraded', 'maxDepth===1 must degrade dispatch, not grant full nesting');
|
||||
assert.match(flat.fallback, /flat dispatch/, 'the documented fallback is flat/inline waves');
|
||||
|
||||
// Prove maxDepth is the cause: at depth 2 the same axes grant full dispatch.
|
||||
const deeper = { ...CODEX_AXES, dispatch: { ...CODEX_AXES.dispatch, maxDepth: 2 } };
|
||||
assert.equal(degradationFor('dispatch', deeper).level, 'full');
|
||||
});
|
||||
|
||||
// -- AC5: negotiation fails CLOSED on a corrupted descriptor ------------------
|
||||
|
||||
test('negotiateHostCapabilities never throws for codex, even fully corrupted', () => {
|
||||
assert.doesNotThrow(() => negotiateHostCapabilities({}));
|
||||
assert.doesNotThrow(() => negotiateHostCapabilities({ ...CODEX_AXES, embeddingMode: UNDOCUMENTED }));
|
||||
assert.doesNotThrow(() => negotiateHostCapabilities({ ...CODEX_AXES, embeddingMode: 'future-unknown' }));
|
||||
});
|
||||
|
||||
test('a partial/empty codex descriptor degrades to the safe floor, not the declarative-cli baseline', () => {
|
||||
const result = negotiateHostCapabilities({});
|
||||
assert.equal(result.effective.embeddingMode, 'declarative', 'omitted embeddingMode degrades closed');
|
||||
assert.equal(result.effective.hookBus, 'none');
|
||||
assert.notDeepEqual(result.effective, PROFILE_BASELINES['declarative-cli']);
|
||||
assert.ok(result.warnings.length > 0);
|
||||
});
|
||||
|
||||
// -- AC2: the hardcoded projection is retired --------------------------------
|
||||
|
||||
test('codex descriptor declares runtime.hostBehaviors (the folded-in behaviors)', () => {
|
||||
const hb = CODEX_CAP.runtime.hostBehaviors;
|
||||
assert.ok(hb && typeof hb === 'object');
|
||||
assert.equal(hb.tomlConfigInstall, true, 'config.toml + agent-toml + hooks.json install runs through the descriptor gate');
|
||||
assert.equal(hb.cleanupSkillSidecars, true);
|
||||
assert.equal(hb.agentTomlFiles, true);
|
||||
assert.equal(hb.frontmatterDialect, 'codex');
|
||||
assert.equal(hb.reapplyCommand, '$gsd-update --reapply');
|
||||
});
|
||||
|
||||
test('no `runtime === "codex"` string-equality and no positive `isCodex` gate remain in the install source (AC2)', () => {
|
||||
const strip = (src) => src
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/\/\/[^\r\n]*/g, '')
|
||||
.replace(/`[^`]*`/g, '');
|
||||
for (const rel of ['bin/install.js', 'src/install-engine.cts', 'src/runtime-artifact-conversion.cts']) {
|
||||
const raw = fs.readFileSync(path.join(__dirname, '..', rel), 'utf8');
|
||||
const src = strip(raw);
|
||||
|
||||
const stringEq = src.match(/runtime\s*[!=]==\s*'codex'/g) || [];
|
||||
assert.deepEqual(stringEq, [], `AC2: no hardcoded runtime==='codex' branch may remain in ${rel}; found: ${stringEq.join(', ')}`);
|
||||
|
||||
// Every `isCodex` reference must be either a runtimeFlags(...) destructure
|
||||
// line or a NEGATED occurrence inside a shared multi-runtime roster
|
||||
// (`!isCodex && !isCopilot && ...`). A positive `isCodex` gate is forbidden.
|
||||
for (const line of src.split(/\r?\n/)) {
|
||||
if (!/\bisCodex\b/.test(line)) continue;
|
||||
if (/runtimeFlags\s*\(/.test(line)) continue; // destructure declaration
|
||||
const positive = line.replace(/!\s*isCodex\b/g, '').match(/\bisCodex\b/);
|
||||
assert.equal(positive, null, `AC2: positive isCodex gate forbidden in ${rel}: ${line.trim()}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// -- AC4 upgrade 3: skill root is the canonical $HOME/.agents/skills ---------
|
||||
|
||||
test('upgrade 3 — codex skills resolve to $HOME/.agents/skills, not the deprecated $CODEX_HOME/skills', () => {
|
||||
const codexHome = path.join(os.homedir(), '.codex');
|
||||
const root = install._resolveSkillsRootDir('codex', codexHome, 'global');
|
||||
assert.equal(root, path.join(os.homedir(), '.agents', 'skills'),
|
||||
'skills must install to the canonical ~/.agents/skills root');
|
||||
assert.ok(!root.startsWith(codexHome), 'skills must NOT land under the deprecated $CODEX_HOME/skills');
|
||||
});
|
||||
|
||||
test('upgrade 3 — the pre-move location is migrated (stale ~/.codex/skills/gsd-* cleaned; user content preserved)', () => {
|
||||
const codexHome = path.join(os.homedir(), '.codex');
|
||||
const oldDir = install._resolveMovedSkillsOldDir('codex', codexHome, 'global');
|
||||
assert.equal(oldDir, path.join(codexHome, 'skills'), 'the pre-move location is $CODEX_HOME/skills');
|
||||
// A runtime with no home override yields null (no false migration).
|
||||
assert.equal(install._resolveMovedSkillsOldDir('kimi', path.join(os.homedir(), '.kimi'), 'global'), null);
|
||||
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-migrate-'));
|
||||
const skillsDir = path.join(tmp, 'skills');
|
||||
fs.mkdirSync(path.join(skillsDir, 'gsd-plan'), { recursive: true });
|
||||
fs.mkdirSync(path.join(skillsDir, 'gsd-dev-preferences'), { recursive: true }); // user-owned, preserved
|
||||
fs.mkdirSync(path.join(skillsDir, 'my-own-skill'), { recursive: true }); // non-gsd, preserved
|
||||
try {
|
||||
const removed = install.cleanupMovedSkillsOldLocation(skillsDir, 'gsd-');
|
||||
assert.equal(removed, 1, 'only the managed gsd-plan dir is removed');
|
||||
assert.ok(!fs.existsSync(path.join(skillsDir, 'gsd-plan')), 'stale managed skill removed');
|
||||
assert.ok(fs.existsSync(path.join(skillsDir, 'gsd-dev-preferences')), 'user-owned gsd-dev-preferences preserved');
|
||||
assert.ok(fs.existsSync(path.join(skillsDir, 'my-own-skill')), 'non-gsd dir preserved');
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
// -- AC4 upgrade 1: the 6 new hooks.json lifecycle events are registered ------
|
||||
|
||||
test('upgrade 1 — codex registers all documented hooks.json lifecycle events, incl. the 6 new in #2088', () => {
|
||||
const expected = [
|
||||
'SubagentStart', 'Stop', 'PostToolUse', // #772
|
||||
'PreToolUse', 'PermissionRequest', 'PreCompact', 'PostCompact', 'SubagentStop', 'UserPromptSubmit', // #2088
|
||||
];
|
||||
assert.deepEqual(install.CODEX_EXTENDED_HOOK_EVENTS, expected,
|
||||
'the shared install/uninstall event list must contain the 3 original + 6 new events');
|
||||
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-hooks-'));
|
||||
try {
|
||||
fs.mkdirSync(path.join(tmp, 'hooks'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmp, 'hooks', 'gsd-context-monitor.js'), '// stub');
|
||||
fs.writeFileSync(path.join(tmp, 'hooks', 'gsd-check-update.js'), '// stub');
|
||||
for (const ev of install.CODEX_EXTENDED_HOOK_EVENTS) {
|
||||
install.ensureCodexHooksJsonEvent(tmp, ev, { absoluteRunner: '/usr/bin/node', platform: 'linux' });
|
||||
}
|
||||
install.ensureCodexHooksJsonSessionStart(tmp, { absoluteRunner: '/usr/bin/node', platform: 'linux' });
|
||||
const hooksJson = JSON.parse(fs.readFileSync(path.join(tmp, 'hooks.json'), 'utf8'));
|
||||
const registered = Object.keys(hooksJson.hooks || hooksJson || {});
|
||||
for (const ev of ['PreToolUse', 'PermissionRequest', 'PreCompact', 'PostCompact', 'SubagentStop', 'UserPromptSubmit']) {
|
||||
assert.ok(registered.includes(ev), `#2088 must register the ${ev} hook in hooks.json`);
|
||||
}
|
||||
assert.ok(registered.includes('SessionStart'), 'the SessionStart baseline stays registered');
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
test('upgrade 1 — extendedHookEvents descriptor reconciled to the schema-valid wired subset (no longer [])', () => {
|
||||
assert.deepEqual(CODEX_CAP.runtime.extendedHookEvents, ['SubagentStop', 'Stop', 'PreCompact'],
|
||||
'reconciled from [] to the wired extended-lifecycle events expressible in the cross-runtime vocabulary');
|
||||
});
|
||||
|
||||
// -- AC4 upgrade 2: explicit [agents] max_depth dispatch tuning ---------------
|
||||
|
||||
test('upgrade 2 — the managed config block writes [agents] max_depth = 1', () => {
|
||||
const block = install.generateCodexConfigBlock(
|
||||
[{ name: 'gsd-foo', description: 'Foo' }, { name: 'gsd-bar', description: 'Bar' }],
|
||||
path.join(os.homedir(), '.codex'),
|
||||
);
|
||||
assert.match(block, /\[agents\]\nmax_depth = 1\n/, 'the block pins max_depth = 1 on a bare [agents] table');
|
||||
// The bare [agents] scalar table coexists with the [agents.gsd-*] role tables.
|
||||
assert.match(block, /\[agents\.gsd-foo\]/);
|
||||
});
|
||||
|
||||
test('upgrade 2 — validateCodexConfigSchema accepts the managed [agents] block but still rejects break-forms', () => {
|
||||
const block = install.generateCodexConfigBlock([{ name: 'gsd-foo', description: 'Foo' }], path.join(os.homedir(), '.codex'));
|
||||
assert.equal(install.validateCodexConfigSchema(block).ok, true, 'known-scalar [agents] + role tables must validate');
|
||||
|
||||
// Still rejects the actual #2760 break-forms.
|
||||
assert.equal(install.validateCodexConfigSchema('[[agents]]\nname = "x"\n').ok, false, '[[agents]] sequence still rejected');
|
||||
assert.equal(install.validateCodexConfigSchema('[agents]\ndefault = "x"\n').ok, false, 'bare [agents] with an unknown key still rejected');
|
||||
|
||||
// A user's own AgentsToml scalar table is now accepted (no longer over-rejected).
|
||||
assert.equal(install.validateCodexConfigSchema('[agents]\nmax_threads = 4\n').ok, true, 'user known-scalar [agents] accepted');
|
||||
assert.equal(install.codexBareAgentsHasOnlyKnownScalars('max_depth = 1\n'), true);
|
||||
assert.equal(install.codexBareAgentsHasOnlyKnownScalars('default = "x"\n'), false);
|
||||
});
|
||||
|
||||
test('upgrade 2 — uninstall removes the managed [agents] max_depth block', () => {
|
||||
const block = install.generateCodexConfigBlock([{ name: 'gsd-foo', description: 'Foo' }], path.join(os.homedir(), '.codex'));
|
||||
const stripped = install.stripGsdFromCodexConfig('model = "gpt-5"\n\n' + block);
|
||||
assert.ok(stripped === null || !/\[agents\]/.test(stripped), `uninstall must remove the managed [agents] block; got: ${JSON.stringify(stripped)}`);
|
||||
});
|
||||
|
||||
// Regression (#2088 review): install must NOT silently drop a user's own
|
||||
// AgentsToml scalar tuning when it purges the bare [agents] table to add max_depth.
|
||||
test('upgrade 2 — install preserves the user\'s own AgentsToml scalars (max_threads etc.), GSD-manages max_depth', () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-agents-merge-'));
|
||||
try {
|
||||
const cfgPath = path.join(tmp, 'config.toml');
|
||||
fs.writeFileSync(cfgPath, '[agents]\nmax_threads = 4\nmax_depth = 9\ninterrupt_message = false\n\n[model]\nname = "o3"\n');
|
||||
|
||||
// extract helper: user scalars except the GSD-managed max_depth.
|
||||
const scalars = install.extractCodexUserAgentsScalars(fs.readFileSync(cfgPath, 'utf8'));
|
||||
assert.ok(scalars.includes('max_threads = 4'), 'max_threads is a preserved user scalar');
|
||||
assert.ok(scalars.includes('interrupt_message = false'), 'interrupt_message is a preserved user scalar');
|
||||
assert.ok(!scalars.some((s) => s.startsWith('max_depth')), 'max_depth is GSD-managed, not preserved from the user');
|
||||
|
||||
install.mergeCodexConfig(cfgPath, install.generateCodexConfigBlock([{ name: 'gsd-foo', description: 'Foo' }], tmp));
|
||||
const merged = fs.readFileSync(cfgPath, 'utf8');
|
||||
|
||||
assert.match(merged, /max_threads = 4/, 'user max_threads must survive install');
|
||||
assert.match(merged, /interrupt_message = false/, 'user interrupt_message must survive install');
|
||||
assert.match(merged, /max_depth = 1/, 'GSD pins max_depth = 1');
|
||||
assert.doesNotMatch(merged, /max_depth = 9/, 'user max_depth is overridden by the GSD-managed value');
|
||||
assert.equal((merged.match(/^\[agents\]$/mg) || []).length, 1, 'exactly one managed [agents] table (no duplicate)');
|
||||
assert.equal(install.validateCodexConfigSchema(merged).ok, true, 'the merged config still validates');
|
||||
|
||||
// Symmetric: uninstall restores the user's scalars and drops GSD's max_depth.
|
||||
const uninstalled = install.stripGsdFromCodexConfig(merged);
|
||||
assert.match(uninstalled, /max_threads = 4/, 'uninstall restores the user\'s max_threads');
|
||||
assert.match(uninstalled, /interrupt_message = false/, 'uninstall restores interrupt_message');
|
||||
assert.doesNotMatch(uninstalled, /max_depth/, 'uninstall drops the GSD-managed max_depth');
|
||||
assert.doesNotMatch(uninstalled, /gsd-foo/, 'uninstall removes the gsd role table');
|
||||
assert.equal(install.validateCodexConfigSchema(uninstalled).ok, true, 'the restored config validates');
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
71
tests/cursor-dispatch-upgrade.test.cjs
Normal file
71
tests/cursor-dispatch-upgrade.test.cjs
Normal file
@@ -0,0 +1,71 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* cursor dispatch UPGRADE — ADR-1239 / #2089 AC4b.
|
||||
*
|
||||
* Proves GSD's wave-based execution drives Cursor's native named/background
|
||||
* nested subagent dispatch (background:true, backgroundDispatch:true,
|
||||
* nested:true, maxDepth:2) instead of flattening to inline sequential calls.
|
||||
*
|
||||
* Cite:
|
||||
* https://cursor.com/docs/subagents — named + background dispatch
|
||||
* https://cursor.com/docs/sdk/typescript — nested subagent depth-2 constraint
|
||||
*/
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const { shouldFlattenDispatch } = require('../gsd-core/bin/lib/host-integration.cjs');
|
||||
|
||||
const CUR_CAP = JSON.parse(
|
||||
fs.readFileSync(path.join(__dirname, '..', 'capabilities', 'cursor', 'capability.json'), 'utf8'),
|
||||
);
|
||||
const CUR_DISPATCH = CUR_CAP.runtime.hostIntegration.dispatch;
|
||||
|
||||
// -- AC4b: cursor dispatch axes ----------------------------------------------
|
||||
|
||||
test('cursor dispatch declares namedDispatch + nested + background + backgroundDispatch', () => {
|
||||
assert.equal(CUR_DISPATCH.namedDispatch, true,
|
||||
'cite https://cursor.com/docs/subagents — named subagent invocation');
|
||||
assert.equal(CUR_DISPATCH.nested, true,
|
||||
'cite https://cursor.com/docs/sdk/typescript — nested subagents');
|
||||
assert.equal(CUR_DISPATCH.background, true,
|
||||
'cite https://cursor.com/docs/subagents — background dispatch');
|
||||
assert.equal(CUR_DISPATCH.backgroundDispatch, true,
|
||||
'cite https://cursor.com/docs/subagents FAQ — subagents can launch child subagents');
|
||||
});
|
||||
|
||||
test('cursor dispatch respects maxDepth: 2 (the documented constraint)', () => {
|
||||
assert.equal(CUR_DISPATCH.maxDepth, 2,
|
||||
'cite https://cursor.com/docs/sdk/typescript — "a subagent launched by another subagent can\'t launch further"');
|
||||
});
|
||||
|
||||
// -- AC4b: shouldFlattenDispatch returns false (NOT force-flattened) ----------
|
||||
|
||||
test('shouldFlattenDispatch(cursor) is false — GSD uses native background dispatch', () => {
|
||||
assert.equal(shouldFlattenDispatch(CUR_DISPATCH), false,
|
||||
'cursor has background:true + backgroundDispatch:true → GSD must NOT force-flatten');
|
||||
});
|
||||
|
||||
test('pre-upgrade cursor axes (background:false) DID force-flatten', () => {
|
||||
const preUpgrade = { ...CUR_DISPATCH, background: false, backgroundDispatch: 'undocumented' };
|
||||
assert.equal(shouldFlattenDispatch(preUpgrade), true,
|
||||
'pre-upgrade cursor (no background dispatch) was force-flattened — the behavioral change #2089 lands');
|
||||
});
|
||||
|
||||
test('shouldFlattenDispatch is true when backgroundDispatch is false (depth-2 but no bg dispatch)', () => {
|
||||
const noBgDispatch = { ...CUR_DISPATCH, backgroundDispatch: false };
|
||||
assert.equal(shouldFlattenDispatch(noBgDispatch), true,
|
||||
'background without backgroundDispatch still flattens (the #853 rule)');
|
||||
});
|
||||
|
||||
// -- AC4b: boundary — maxDepth 2 is the discriminator vs unbounded -----------
|
||||
|
||||
test('maxDepth 2 is the documented constraint (not -1 unbounded)', () => {
|
||||
assert.notEqual(CUR_DISPATCH.maxDepth, -1,
|
||||
'cursor is NOT unbounded — depth-2 is the documented hard limit');
|
||||
assert.ok(CUR_DISPATCH.maxDepth > 0 && CUR_DISPATCH.maxDepth <= 2,
|
||||
'maxDepth must be a positive integer ≤ 2 per cursor docs');
|
||||
});
|
||||
173
tests/cursor-hook-bus-upgrade.test.cjs
Normal file
173
tests/cursor-hook-bus-upgrade.test.cjs
Normal file
@@ -0,0 +1,173 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* cursor hook-bus UPGRADE — ADR-1239 / #2089 AC4a.
|
||||
*
|
||||
* Proves the expanded hook-bus coverage: GSD registers for subagentStart,
|
||||
* subagentStop, preToolUse, and stop IN ADDITION to the baseline sessionStart
|
||||
* and postToolUse. Cite: https://cursor.com/docs/hooks
|
||||
*
|
||||
* Tests the descriptor-driven adapter module (pure) + the reconcile behavior
|
||||
* (all 6 managed events in the generated hooks.json).
|
||||
*/
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
|
||||
const {
|
||||
CURSOR_HOOK_EVENTS,
|
||||
CURSOR_EVENT_SCRIPT_MAP,
|
||||
resolveManagedHookEvents,
|
||||
resolveHookScripts,
|
||||
} = require('../gsd-core/bin/lib/host-integration-adapters/imperative-hook-bus.cjs');
|
||||
|
||||
const {
|
||||
reconcileCursorHooksJson,
|
||||
GSD_CURSOR_HOOK_MARKER,
|
||||
} = require('../bin/install.js');
|
||||
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const CUR_CAP = JSON.parse(
|
||||
fs.readFileSync(path.join(__dirname, '..', 'capabilities', 'cursor', 'capability.json'), 'utf8'),
|
||||
);
|
||||
|
||||
const EXPECTED_EVENTS = [
|
||||
'sessionStart',
|
||||
'postToolUse',
|
||||
'preToolUse',
|
||||
'stop',
|
||||
'subagentStart',
|
||||
'subagentStop',
|
||||
];
|
||||
|
||||
// -- AC4a: the adapter declares all 6 managed events -------------------------
|
||||
|
||||
test('CURSOR_HOOK_EVENTS contains all 6 managed events', () => {
|
||||
for (const ev of EXPECTED_EVENTS) {
|
||||
assert.ok(CURSOR_HOOK_EVENTS.includes(ev),
|
||||
`CURSOR_HOOK_EVENTS must include ${ev}`);
|
||||
}
|
||||
assert.equal(CURSOR_HOOK_EVENTS.length, 6,
|
||||
'exactly 6 managed events (no extras)');
|
||||
});
|
||||
|
||||
test('CURSOR_EVENT_SCRIPT_MAP maps every event to a script', () => {
|
||||
for (const ev of EXPECTED_EVENTS) {
|
||||
const script = CURSOR_EVENT_SCRIPT_MAP[ev];
|
||||
assert.ok(typeof script === 'string' && script.endsWith('.js'),
|
||||
`${ev} must map to a .js script, got: ${script}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('descriptor managedHookEvents matches the adapter event list', () => {
|
||||
const declared = CUR_CAP.runtime.hostBehaviors.managedHookEvents;
|
||||
assert.deepEqual(declared.sort(), [...EXPECTED_EVENTS].sort(),
|
||||
'descriptor managedHookEvents must match the 6-event managed set');
|
||||
});
|
||||
|
||||
// -- AC4a: resolveManagedHookEvents + resolveHookScripts ---------------------
|
||||
|
||||
test('resolveManagedHookEvents returns all 6 from the descriptor list', () => {
|
||||
const resolved = resolveManagedHookEvents(CUR_CAP.runtime.hostBehaviors.managedHookEvents);
|
||||
assert.equal(resolved.length, 6);
|
||||
for (const ev of EXPECTED_EVENTS) {
|
||||
assert.ok(resolved.includes(ev), `resolveManagedHookEvents must include ${ev}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('resolveManagedHookEvents filters unknown events (fail-closed)', () => {
|
||||
const resolved = resolveManagedHookEvents(['sessionStart', 'bogusEvent', 'stop']);
|
||||
assert.deepEqual([...resolved].sort(), ['sessionStart', 'stop']);
|
||||
});
|
||||
|
||||
test('resolveManagedHookEvents falls back to full set when descriptor is empty', () => {
|
||||
const resolved = resolveManagedHookEvents(null);
|
||||
assert.equal(resolved.length, 6);
|
||||
});
|
||||
|
||||
test('resolveHookScripts returns a script for every managed event', () => {
|
||||
const scripts = resolveHookScripts(EXPECTED_EVENTS);
|
||||
assert.equal(scripts.length, 6);
|
||||
for (const s of scripts) {
|
||||
assert.ok(s.startsWith('gsd-cursor-') && s.endsWith('.js'),
|
||||
`script must follow gsd-cursor-*.js convention: ${s}`);
|
||||
}
|
||||
});
|
||||
|
||||
// -- AC4a: hook scripts exist on disk ---------------------------------------
|
||||
|
||||
test('all 6 hook scripts exist under hooks/', () => {
|
||||
for (const ev of EXPECTED_EVENTS) {
|
||||
const script = CURSOR_EVENT_SCRIPT_MAP[ev];
|
||||
const scriptPath = path.join(__dirname, '..', 'hooks', script);
|
||||
assert.ok(fs.existsSync(scriptPath),
|
||||
`hook script must exist: hooks/${script} (event: ${ev})`);
|
||||
}
|
||||
});
|
||||
|
||||
// -- AC4a: reconcile generates hooks.json with all 6 events ------------------
|
||||
|
||||
test('reconcileCursorHooksJson writes all 6 managed events into hooks.json', (t) => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cursor-hook-bus-'));
|
||||
t.after(() => cleanup(tmpDir));
|
||||
const hooksJsonPath = path.join(tmpDir, 'hooks.json');
|
||||
const managedEntries = {};
|
||||
for (const ev of EXPECTED_EVENTS) {
|
||||
managedEntries[ev] = {
|
||||
type: 'command',
|
||||
command: `node /fake/${ev}.js`,
|
||||
[GSD_CURSOR_HOOK_MARKER]: true,
|
||||
};
|
||||
}
|
||||
const result = reconcileCursorHooksJson(hooksJsonPath, managedEntries);
|
||||
assert.ok(result.changed, 'first write must report changed=true');
|
||||
|
||||
const written = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8'));
|
||||
const hookTable = written.hooks;
|
||||
assert.ok(hookTable && typeof hookTable === 'object');
|
||||
for (const ev of EXPECTED_EVENTS) {
|
||||
assert.ok(Array.isArray(hookTable[ev]),
|
||||
`hooks.json must have a ${ev} array`);
|
||||
assert.equal(hookTable[ev].length, 1,
|
||||
`${ev} must have exactly 1 managed entry`);
|
||||
assert.equal(hookTable[ev][0][GSD_CURSOR_HOOK_MARKER], true,
|
||||
`${ev} entry must carry the GSD managed marker`);
|
||||
}
|
||||
});
|
||||
|
||||
test('reconcileCursorHooksJson preserves user entries across all 6 events', (t) => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cursor-hook-bus-'));
|
||||
t.after(() => cleanup(tmpDir));
|
||||
const hooksJsonPath = path.join(tmpDir, 'hooks.json');
|
||||
// Seed with user-owned entries in two events.
|
||||
const seed = {
|
||||
version: 1,
|
||||
hooks: {
|
||||
sessionStart: [{ type: 'command', command: 'user-start.sh' }],
|
||||
preToolUse: [{ type: 'command', command: 'user-pre.sh' }],
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(hooksJsonPath, JSON.stringify(seed, null, 2) + '\n');
|
||||
|
||||
const managedEntries = {};
|
||||
for (const ev of EXPECTED_EVENTS) {
|
||||
managedEntries[ev] = {
|
||||
type: 'command',
|
||||
command: `node /gsd/${ev}.js`,
|
||||
[GSD_CURSOR_HOOK_MARKER]: true,
|
||||
};
|
||||
}
|
||||
reconcileCursorHooksJson(hooksJsonPath, managedEntries);
|
||||
|
||||
const written = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8'));
|
||||
// sessionStart: 1 user + 1 managed
|
||||
assert.equal(written.hooks.sessionStart.length, 2);
|
||||
// preToolUse: 1 user + 1 managed
|
||||
assert.equal(written.hooks.preToolUse.length, 2);
|
||||
// postToolUse: 1 managed only
|
||||
assert.equal(written.hooks.postToolUse.length, 1);
|
||||
});
|
||||
126
tests/cursor-imperative-reference.test.cjs
Normal file
126
tests/cursor-imperative-reference.test.cjs
Normal file
@@ -0,0 +1,126 @@
|
||||
// allow-test-rule: AC2 requires asserting no `runtime === 'cursor'` string-equality branch remains in bin/install.js/src — the descriptor-migration contract is a property of the source text, so a source-grep is the only faithful check (#2089)
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* cursor imperative reference host — ADR-1239 Phase D / #2089 (EoS/cursor).
|
||||
*
|
||||
* Proves cursor is driven through the PUBLIC Host-Integration Interface (the
|
||||
* imperative adapter), that its negotiated axes classify + negotiate correctly,
|
||||
* that negotiation fails CLOSED on a corrupted descriptor, that the Context7-
|
||||
* verified dispatch UPGRADE (named/background nested subagents) changes
|
||||
* `shouldFlattenDispatch`, and that the migration retired the hardcoded
|
||||
* `runtime === 'cursor'` / `isCursor` branches (folded into descriptor-driven
|
||||
* `runtime.hostBehaviors`).
|
||||
*/
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const { createImperativeAdapter } = require('../gsd-core/bin/lib/adapter-imperative.cjs');
|
||||
const {
|
||||
profileOf,
|
||||
negotiateHostCapabilities,
|
||||
shouldFlattenDispatch,
|
||||
PROFILE_BASELINES,
|
||||
UNDOCUMENTED,
|
||||
} = require('../gsd-core/bin/lib/host-integration.cjs');
|
||||
|
||||
const CUR_CAP = JSON.parse(
|
||||
fs.readFileSync(path.join(__dirname, '..', 'capabilities', 'cursor', 'capability.json'), 'utf8'),
|
||||
);
|
||||
const CUR_AXES = CUR_CAP.runtime.hostIntegration;
|
||||
|
||||
// -- AC2: driven through the public interface (imperative adapter) -----------
|
||||
|
||||
test('createImperativeAdapter classifies cursor as imperative + composes the registry', () => {
|
||||
const adapter = createImperativeAdapter({ runtime: 'cursor' });
|
||||
assert.equal(adapter.kind, 'imperative');
|
||||
assert.equal(adapter.runtime, 'cursor');
|
||||
assert.ok(adapter.registry && typeof adapter.registry === 'object');
|
||||
assert.equal(typeof adapter.install, 'function');
|
||||
assert.equal(typeof adapter.uninstall, 'function');
|
||||
});
|
||||
|
||||
test('cursor axes classify as the programmatic-cli reference profile', () => {
|
||||
assert.equal(profileOf(CUR_AXES), 'programmatic-cli');
|
||||
});
|
||||
|
||||
// -- AC3: all axes populated + validated -------------------------------------
|
||||
|
||||
test('cursor descriptor declares all 8 axes + 6 dispatch sub-axes (no undocumented)', () => {
|
||||
assert.equal(CUR_AXES.embeddingMode, 'imperative');
|
||||
assert.equal(CUR_AXES.commandSurface, 'slash-file');
|
||||
assert.equal(CUR_AXES.modelMode, 'passive');
|
||||
assert.equal(CUR_AXES.hookBus, 'host');
|
||||
assert.equal(CUR_AXES.stateIO, 'filesystem');
|
||||
assert.equal(CUR_AXES.transport, 'mcp');
|
||||
assert.equal(CUR_AXES.runtime, 'node');
|
||||
const d = CUR_AXES.dispatch;
|
||||
assert.equal(d.namedDispatch, true);
|
||||
assert.equal(d.nested, true);
|
||||
assert.equal(d.maxDepth, 2);
|
||||
assert.equal(d.background, true);
|
||||
assert.equal(d.subagentToolkit, 'full');
|
||||
assert.equal(d.backgroundDispatch, true);
|
||||
});
|
||||
|
||||
// -- AC4b: the Context7-verified dispatch UPGRADE (named/background nested) ---
|
||||
|
||||
test('cursor descriptor declares background dispatch true/true + nested + maxDepth 2', () => {
|
||||
assert.equal(CUR_AXES.dispatch.background, true);
|
||||
assert.equal(CUR_AXES.dispatch.backgroundDispatch, true);
|
||||
assert.equal(CUR_AXES.dispatch.nested, true);
|
||||
assert.equal(CUR_AXES.dispatch.maxDepth, 2, 'cite https://cursor.com/docs/sdk/typescript');
|
||||
});
|
||||
|
||||
test('dispatch UPGRADE changes shouldFlattenDispatch: false now (may background), true for pre-upgrade axes', () => {
|
||||
assert.equal(shouldFlattenDispatch(CUR_AXES.dispatch), false,
|
||||
'with background:true+backgroundDispatch:true, GSD must NOT force-flatten cursor dispatch');
|
||||
const preUpgrade = { ...CUR_AXES.dispatch, background: false, backgroundDispatch: 'undocumented' };
|
||||
assert.equal(shouldFlattenDispatch(preUpgrade), true,
|
||||
'pre-upgrade (background:false) cursor was force-flattened — this is the behavioral change #2089 lands');
|
||||
});
|
||||
|
||||
// -- AC5: negotiation fails CLOSED on a corrupted descriptor ------------------
|
||||
|
||||
test('negotiateHostCapabilities never throws for cursor, even fully corrupted', () => {
|
||||
assert.doesNotThrow(() => negotiateHostCapabilities({}));
|
||||
assert.doesNotThrow(() => negotiateHostCapabilities({ ...CUR_AXES, embeddingMode: UNDOCUMENTED }));
|
||||
assert.doesNotThrow(() => negotiateHostCapabilities({ ...CUR_AXES, embeddingMode: 'future-unknown' }));
|
||||
});
|
||||
|
||||
test('a partial/empty cursor descriptor degrades to the safe floor, not the programmatic-cli baseline', () => {
|
||||
const result = negotiateHostCapabilities({});
|
||||
assert.equal(result.effective.embeddingMode, 'declarative', 'omitted embeddingMode degrades closed');
|
||||
assert.equal(result.effective.hookBus, 'none');
|
||||
assert.notDeepEqual(result.effective, PROFILE_BASELINES['programmatic-cli']);
|
||||
assert.ok(result.warnings.length > 0);
|
||||
});
|
||||
|
||||
// -- AC2: the hardcoded branches are retired ---------------------------------
|
||||
|
||||
test('cursor descriptor declares runtime.hostBehaviors (the folded-in behaviors)', () => {
|
||||
const hb = CUR_CAP.runtime.hostBehaviors;
|
||||
assert.ok(hb && typeof hb === 'object');
|
||||
assert.equal(hb.reapplyCommand, 'gsd-update --reapply (mention the skill name)');
|
||||
assert.equal(hb.frontmatterDialect, 'cursor');
|
||||
assert.equal(hb.hooksJsonSurface, true);
|
||||
assert.equal(hb.skipSharedHooksInstall, true);
|
||||
assert.equal(hb.reportCommandsDir, true);
|
||||
assert.ok(Array.isArray(hb.managedHookEvents) && hb.managedHookEvents.length >= 6,
|
||||
'managedHookEvents must list at least 6 events (AC4a)');
|
||||
});
|
||||
|
||||
test('no `runtime === "cursor"` string-equality branch remains in the install source (AC2)', () => {
|
||||
const strip = (src) => src
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/\/\/[^\r\n]*/g, '')
|
||||
.replace(/`[^`]*`/g, '');
|
||||
for (const rel of ['bin/install.js', 'src/install-engine.cts', 'src/runtime-artifact-conversion.cts', 'src/runtime-hooks-surface.cts']) {
|
||||
const src = fs.readFileSync(path.join(__dirname, '..', rel), 'utf8');
|
||||
const offenders = strip(src).match(/runtime\s*[!=]==\s*'cursor'/g) || [];
|
||||
assert.deepEqual(offenders, [], `AC2: no hardcoded runtime==='cursor' branch may remain in ${rel}; found: ${offenders.join(', ')}`);
|
||||
}
|
||||
});
|
||||
@@ -284,7 +284,7 @@
|
||||
"gsd-core/workflows/remove-phase.md": "23b9eb0858a2535e",
|
||||
"gsd-core/workflows/remove-workspace.md": "d0bd7e0601138798",
|
||||
"gsd-core/workflows/resume-project.md": "98e2cf8908e73a52",
|
||||
"gsd-core/workflows/review.md": "9bc686206c96748c",
|
||||
"gsd-core/workflows/review.md": "9ff117dd12ce68ba",
|
||||
"gsd-core/workflows/scan.md": "a7fecd67e5cd655f",
|
||||
"gsd-core/workflows/secure-phase.md": "96b199dfac00e60f",
|
||||
"gsd-core/workflows/session-report.md": "2e5b1205324ddefa",
|
||||
@@ -315,7 +315,11 @@
|
||||
"hooks/gsd-config-reload.js": "96546e0e8bb47904",
|
||||
"hooks/gsd-context-monitor.js": "6d81d7326e5b2710",
|
||||
"hooks/gsd-cursor-post-tool.js": "9168e0a09de1972a",
|
||||
"hooks/gsd-cursor-pre-tool.js": "873998b25e308c29",
|
||||
"hooks/gsd-cursor-session-start.js": "9b2e6f4f0c405375",
|
||||
"hooks/gsd-cursor-stop.js": "bfaaf60f419e3238",
|
||||
"hooks/gsd-cursor-subagent-start.js": "06d77fde5c1372b6",
|
||||
"hooks/gsd-cursor-subagent-stop.js": "4bbf22917da4d389",
|
||||
"hooks/gsd-ensure-canonical-path.js": "64d092d7e4a01211",
|
||||
"hooks/gsd-graphify-update.sh": "e4c6e14fe6ad64ff",
|
||||
"hooks/gsd-phase-boundary.sh": "32739d5fbe0d0a1c",
|
||||
@@ -330,7 +334,7 @@
|
||||
"hooks/gsd-worktree-path-guard.js": "838498aa91619740",
|
||||
"hooks/lib/git-cmd.js": "268ba15992ca0b23",
|
||||
"hooks/lib/gsd-graphify-rebuild.sh": "66af89601074d2a9",
|
||||
"hooks/managed-hooks-registry.cjs": "45b2431992d3d7d2",
|
||||
"hooks/managed-hooks-registry.cjs": "721d696556b7509f",
|
||||
"package.json": "dbf8353f77358bc1",
|
||||
"scripts/changeset/README.md": "86ff89331dfd94b2",
|
||||
"scripts/changeset/cli.cjs": "68f92a344b199271",
|
||||
|
||||
@@ -355,7 +355,7 @@
|
||||
"gsd-core/workflows/remove-phase.md": "df9a45f0b1880999",
|
||||
"gsd-core/workflows/remove-workspace.md": "a7ca66db6b7c132c",
|
||||
"gsd-core/workflows/resume-project.md": "f28da1200e4545f4",
|
||||
"gsd-core/workflows/review.md": "f3c2e894941c9943",
|
||||
"gsd-core/workflows/review.md": "93fe46bd0b5dd3d1",
|
||||
"gsd-core/workflows/scan.md": "003883d71c37da7d",
|
||||
"gsd-core/workflows/secure-phase.md": "29fc6b62c5c5dc62",
|
||||
"gsd-core/workflows/session-report.md": "2e5b1205324ddefa",
|
||||
@@ -386,7 +386,11 @@
|
||||
"hooks/gsd-config-reload.js": "96546e0e8bb47904",
|
||||
"hooks/gsd-context-monitor.js": "44ff1bbf292747af",
|
||||
"hooks/gsd-cursor-post-tool.js": "9168e0a09de1972a",
|
||||
"hooks/gsd-cursor-pre-tool.js": "873998b25e308c29",
|
||||
"hooks/gsd-cursor-session-start.js": "9b2e6f4f0c405375",
|
||||
"hooks/gsd-cursor-stop.js": "bfaaf60f419e3238",
|
||||
"hooks/gsd-cursor-subagent-start.js": "06d77fde5c1372b6",
|
||||
"hooks/gsd-cursor-subagent-stop.js": "4bbf22917da4d389",
|
||||
"hooks/gsd-ensure-canonical-path.js": "d569f5f3578e93e5",
|
||||
"hooks/gsd-graphify-update.sh": "e4c6e14fe6ad64ff",
|
||||
"hooks/gsd-phase-boundary.sh": "32739d5fbe0d0a1c",
|
||||
@@ -401,7 +405,7 @@
|
||||
"hooks/gsd-worktree-path-guard.js": "65b934c3a1709e89",
|
||||
"hooks/lib/git-cmd.js": "268ba15992ca0b23",
|
||||
"hooks/lib/gsd-graphify-rebuild.sh": "66af89601074d2a9",
|
||||
"hooks/managed-hooks-registry.cjs": "f46a329fcfefa465",
|
||||
"hooks/managed-hooks-registry.cjs": "e61da0f7a3037c35",
|
||||
"package.json": "dbf8353f77358bc1",
|
||||
"scripts/changeset/README.md": "86ff89331dfd94b2",
|
||||
"scripts/changeset/cli.cjs": "68f92a344b199271",
|
||||
|
||||
422
tests/fixtures/golden-install-parity/claude-local.json
vendored
Normal file
422
tests/fixtures/golden-install-parity/claude-local.json
vendored
Normal file
@@ -0,0 +1,422 @@
|
||||
{
|
||||
".gsd-profile": "0e716a5fef4e6dc1",
|
||||
"agents/gsd-advisor-researcher.md": "c81b55d567dcd99e",
|
||||
"agents/gsd-ai-researcher.md": "27c7df941b01cc13",
|
||||
"agents/gsd-assumptions-analyzer.md": "d796f8245bbe05d2",
|
||||
"agents/gsd-code-fixer.md": "c6148e5511d02459",
|
||||
"agents/gsd-code-reviewer.md": "e2c45baa8c0b5f6d",
|
||||
"agents/gsd-codebase-mapper.md": "f96958e5f85b93fb",
|
||||
"agents/gsd-debug-session-manager.md": "ec9ca0011a1aab75",
|
||||
"agents/gsd-debugger.md": "9f35a91f8b3a918e",
|
||||
"agents/gsd-doc-classifier.md": "a76778bdde1c7f72",
|
||||
"agents/gsd-doc-synthesizer.md": "8b0b6fc187c9d353",
|
||||
"agents/gsd-doc-verifier.md": "4232dcf9076e3566",
|
||||
"agents/gsd-doc-writer.md": "22264239fa0ee611",
|
||||
"agents/gsd-domain-researcher.md": "f1e03df842ddfb95",
|
||||
"agents/gsd-eval-auditor.md": "d0f45fff7370bb0b",
|
||||
"agents/gsd-eval-planner.md": "9cc049b82897daa4",
|
||||
"agents/gsd-executor.md": "bf1de739df0c9245",
|
||||
"agents/gsd-framework-selector.md": "85005d716f9d98f7",
|
||||
"agents/gsd-integration-checker.md": "17a8ee731986564d",
|
||||
"agents/gsd-intel-updater.md": "4953a465db9dadc1",
|
||||
"agents/gsd-mempalace-curator.md": "77b53f1b155242b4",
|
||||
"agents/gsd-nyquist-auditor.md": "f86a28f5c164a0d3",
|
||||
"agents/gsd-pattern-mapper.md": "b45b5e106775bec1",
|
||||
"agents/gsd-phase-researcher.md": "4772d9eada32e8bd",
|
||||
"agents/gsd-plan-checker.md": "75851b147f35354a",
|
||||
"agents/gsd-planner.md": "9c9ffc56275b8ca2",
|
||||
"agents/gsd-project-researcher.md": "d7f355894519f9fe",
|
||||
"agents/gsd-research-synthesizer.md": "1c738df9932d325a",
|
||||
"agents/gsd-roadmapper.md": "453e9471ad27c7ea",
|
||||
"agents/gsd-security-auditor.md": "45bd98918cd3a004",
|
||||
"agents/gsd-ui-auditor.md": "a0b09cc8e4645956",
|
||||
"agents/gsd-ui-checker.md": "3a7be21f4daa1c05",
|
||||
"agents/gsd-ui-researcher.md": "a739b0ded9c3ae23",
|
||||
"agents/gsd-user-profiler.md": "d40584599906f3b7",
|
||||
"agents/gsd-verifier.md": "628ef3a944a7a6eb",
|
||||
"commands/gsd-add-tests.md": "057e3e440989e681",
|
||||
"commands/gsd-ai-integration-phase.md": "998dc23188e131fb",
|
||||
"commands/gsd-audit-fix.md": "ebdcc267a2bebaab",
|
||||
"commands/gsd-audit-milestone.md": "319caced85c69ff3",
|
||||
"commands/gsd-audit-uat.md": "99c9af1b2a2c600a",
|
||||
"commands/gsd-autonomous.md": "c256989da0d3e736",
|
||||
"commands/gsd-capture.md": "d3f4353483df3637",
|
||||
"commands/gsd-cleanup.md": "110473269dabd608",
|
||||
"commands/gsd-code-review.md": "d2f3160f3a1a790a",
|
||||
"commands/gsd-complete-milestone.md": "561b8e6bf35ee6e9",
|
||||
"commands/gsd-config.md": "24d7e8cb8237456e",
|
||||
"commands/gsd-debug.md": "488ec45a06eb7afa",
|
||||
"commands/gsd-discuss-phase.md": "e50613dc10e1bab5",
|
||||
"commands/gsd-docs-update.md": "e314a24fd3926ace",
|
||||
"commands/gsd-eval-review.md": "c636c832d9a7f5c0",
|
||||
"commands/gsd-execute-phase.md": "a2c4ad3e635c280a",
|
||||
"commands/gsd-explore.md": "6d5e1f5f14b2d380",
|
||||
"commands/gsd-extract-learnings.md": "e43d320cb3ed1aaa",
|
||||
"commands/gsd-fast.md": "5357a1cf3f363dd2",
|
||||
"commands/gsd-forensics.md": "b816a6cd5ea304f8",
|
||||
"commands/gsd-graphify.md": "a07ffe1827c512e1",
|
||||
"commands/gsd-health.md": "253680291c74b8ed",
|
||||
"commands/gsd-help.md": "eb2c387f97c59e66",
|
||||
"commands/gsd-import.md": "bcd7c5018b6c96b2",
|
||||
"commands/gsd-inbox.md": "e829f45bfe8b4ca5",
|
||||
"commands/gsd-ingest-docs.md": "ded9013d0de7e77b",
|
||||
"commands/gsd-manager.md": "72d5b31b88f77703",
|
||||
"commands/gsd-map-codebase.md": "ecd69887996ae561",
|
||||
"commands/gsd-mempalace-capture.md": "2e49397072506fd9",
|
||||
"commands/gsd-mempalace-recall.md": "38716c0983a3ef9c",
|
||||
"commands/gsd-milestone-summary.md": "908509042caf5beb",
|
||||
"commands/gsd-mvp-phase.md": "1ef0d7c2871be49a",
|
||||
"commands/gsd-new-milestone.md": "8ce4861325cd3862",
|
||||
"commands/gsd-new-project.md": "d68b36481d09bfd4",
|
||||
"commands/gsd-next.md": "e976ddf80b6cb425",
|
||||
"commands/gsd-ns-context.md": "011c44e7aa46e64a",
|
||||
"commands/gsd-ns-ideate.md": "edc5e543512dd48a",
|
||||
"commands/gsd-ns-manage.md": "0409d810e499357f",
|
||||
"commands/gsd-ns-project.md": "ff67e85bc6f7fc5a",
|
||||
"commands/gsd-ns-review.md": "3766ed10827882a0",
|
||||
"commands/gsd-ns-workflow.md": "c3b3c046a74ec0ee",
|
||||
"commands/gsd-onboard.md": "d0d9405bc73899bd",
|
||||
"commands/gsd-pause-work.md": "01dbaebfefacd252",
|
||||
"commands/gsd-phase.md": "e8c226d2694692a5",
|
||||
"commands/gsd-plan-phase.md": "518357828182dca7",
|
||||
"commands/gsd-plan-review-convergence.md": "d5a85a50dcff2dd1",
|
||||
"commands/gsd-pr-branch.md": "382c23a6a644c0e4",
|
||||
"commands/gsd-profile-user.md": "adbc5b025b30e836",
|
||||
"commands/gsd-progress.md": "75a6dc71b74c54c2",
|
||||
"commands/gsd-quick.md": "fe2f6655e67a4223",
|
||||
"commands/gsd-resume-work.md": "cb393bc9f46dcc15",
|
||||
"commands/gsd-review-backlog.md": "434ebe7b63c107e0",
|
||||
"commands/gsd-review.md": "e990b7adb72472f9",
|
||||
"commands/gsd-secure-phase.md": "ca4c23a332ca6204",
|
||||
"commands/gsd-settings.md": "ed3fe2f6c845226d",
|
||||
"commands/gsd-ship.md": "35a93b86512a7dff",
|
||||
"commands/gsd-sketch.md": "c35f09bf0698c0c9",
|
||||
"commands/gsd-spec-phase.md": "383b4928c4df4bf1",
|
||||
"commands/gsd-spike.md": "98d789db7ca53873",
|
||||
"commands/gsd-stats.md": "a9c0e3f338bd61a5",
|
||||
"commands/gsd-surface.md": "998bf327c3f4a001",
|
||||
"commands/gsd-thread.md": "1b78c7b75b53ad4e",
|
||||
"commands/gsd-ui-phase.md": "67a759b3973f961f",
|
||||
"commands/gsd-ui-review.md": "9b4ecf2d40bdc476",
|
||||
"commands/gsd-ultraplan-phase.md": "4b42890e7d7a0c30",
|
||||
"commands/gsd-undo.md": "e3cf64bb9562a0d8",
|
||||
"commands/gsd-update.md": "23342d312e49a125",
|
||||
"commands/gsd-validate-phase.md": "36c1a57cf7e1e724",
|
||||
"commands/gsd-verify-work.md": "246ac304c4dfde48",
|
||||
"commands/gsd-workspace.md": "5928e93b8ab475ac",
|
||||
"commands/gsd-workstreams.md": "52ab9c585d3a00f3",
|
||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62",
|
||||
"gsd-core/bin/gsd-tools.cjs": "6a7616125440c8b1",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "6bba2b9c9fa47cb8",
|
||||
"gsd-core/bin/shared/model-catalog.json": "e554a288fcbc1b2e",
|
||||
"gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a",
|
||||
"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/agent-skills-bootstrap.md": "5ab875054b1adda9",
|
||||
"gsd-core/references/ai-evals.md": "b5afa786b938671e",
|
||||
"gsd-core/references/ai-frameworks.md": "f827de93dde124eb",
|
||||
"gsd-core/references/api-coverage.md": "205a43c5fa7c221c",
|
||||
"gsd-core/references/artifact-types.md": "251040866a3a1818",
|
||||
"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": "6bfac08025ea3106",
|
||||
"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": "36f44960a5c1dc45",
|
||||
"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/gsd-run-resolver.md": "1541604e8301ff6d",
|
||||
"gsd-core/references/honest-verifier.md": "809f3488bc5a79d0",
|
||||
"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": "18e7cfd4ba0ca9bc",
|
||||
"gsd-core/references/model-profiles.md": "c249163663bbea53",
|
||||
"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": "782fa05092be3ffa",
|
||||
"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": "add55e135dd968da",
|
||||
"gsd-core/references/planner-mvp-mode.md": "ffd7b9d0e402714e",
|
||||
"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": "b025429fc72f9285",
|
||||
"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": "2d3b728f8ef5cf6e",
|
||||
"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/reviewer-instances.md": "1412472f858b8f41",
|
||||
"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/specless-probe-fallback.md": "c0331cc8b7df24a8",
|
||||
"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": "cfaf338f42b1111e",
|
||||
"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": "d34c1d0eb4f15ed6",
|
||||
"gsd-core/templates/codebase/testing.md": "76abff7f2050c9ea",
|
||||
"gsd-core/templates/config.json": "a4b783ef759a0f37",
|
||||
"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": "974daa528c2ce3f1",
|
||||
"gsd-core/templates/planner-subagent-prompt.md": "6c9f1b23ee3dc05f",
|
||||
"gsd-core/templates/project.md": "ae1f68db042c2522",
|
||||
"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": "bf2dd5d1debd5335",
|
||||
"gsd-core/workflows/add-backlog.md": "8d05775f367d1e48",
|
||||
"gsd-core/workflows/add-phase.md": "7285e6e8894a41c5",
|
||||
"gsd-core/workflows/add-tests.md": "8012263b2d27b83e",
|
||||
"gsd-core/workflows/add-todo.md": "1fc850476cd8340d",
|
||||
"gsd-core/workflows/ai-integration-phase.md": "3503f52a7356caf0",
|
||||
"gsd-core/workflows/analyze-dependencies.md": "77aff48f97fa6f1c",
|
||||
"gsd-core/workflows/audit-fix.md": "816c71b0ef2d8c1d",
|
||||
"gsd-core/workflows/audit-milestone.md": "a35795246b955bdb",
|
||||
"gsd-core/workflows/audit-uat.md": "1c4a02a8c1ab930f",
|
||||
"gsd-core/workflows/autonomous.md": "6adf957e64518d15",
|
||||
"gsd-core/workflows/check-todos.md": "8f2c6b27f18cc5e2",
|
||||
"gsd-core/workflows/cleanup.md": "bfbab4b981d39544",
|
||||
"gsd-core/workflows/code-review-fix.md": "78c716068ccdf820",
|
||||
"gsd-core/workflows/code-review.md": "2d21452eb0449fdd",
|
||||
"gsd-core/workflows/complete-milestone.md": "9962377cddee50d7",
|
||||
"gsd-core/workflows/debug.md": "3354c726abbfd75b",
|
||||
"gsd-core/workflows/diagnose-issues.md": "db6a599674efbc4d",
|
||||
"gsd-core/workflows/discovery-phase.md": "a20dfb32adec51de",
|
||||
"gsd-core/workflows/discuss-phase-assumptions.md": "35a3b2d1285565d8",
|
||||
"gsd-core/workflows/discuss-phase-power.md": "290c0d83d783f9f6",
|
||||
"gsd-core/workflows/discuss-phase.md": "ff2563dc378c5e5c",
|
||||
"gsd-core/workflows/discuss-phase/modes/advisor.md": "f64ece6d53b6d432",
|
||||
"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": "d0d68b06bcd43bcf",
|
||||
"gsd-core/workflows/discuss-phase/modes/batch.md": "6946597770e2d448",
|
||||
"gsd-core/workflows/discuss-phase/modes/chain.md": "94548620da9708dd",
|
||||
"gsd-core/workflows/discuss-phase/modes/default.md": "67d1b67f61f03966",
|
||||
"gsd-core/workflows/discuss-phase/modes/power.md": "dfcf239382e9bd67",
|
||||
"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": "149084d893a23024",
|
||||
"gsd-core/workflows/docs-update.md": "cd753783ab95da00",
|
||||
"gsd-core/workflows/edit-phase.md": "dbbb6191f5a8b65e",
|
||||
"gsd-core/workflows/eval-review.md": "086a1f2b3c11462c",
|
||||
"gsd-core/workflows/execute-phase.md": "d7d8ac751aa2915e",
|
||||
"gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md": "6d38bfd540030da4",
|
||||
"gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md": "7ebb7d1af6082028",
|
||||
"gsd-core/workflows/execute-phase/steps/post-merge-gate.md": "611b2be3bd133eb1",
|
||||
"gsd-core/workflows/execute-phase/steps/regression-gate.md": "016ac9c7c02b1438",
|
||||
"gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md": "be84efbd71e1513e",
|
||||
"gsd-core/workflows/execute-plan.md": "f17623fd47e795dd",
|
||||
"gsd-core/workflows/explore.md": "95e463d4bdd6dadd",
|
||||
"gsd-core/workflows/extract-learnings.md": "fd75072c339b58bd",
|
||||
"gsd-core/workflows/fast.md": "54fe93778b45a7eb",
|
||||
"gsd-core/workflows/forensics.md": "857d7b064f4cca21",
|
||||
"gsd-core/workflows/graduation.md": "ecf8da93e094fd2e",
|
||||
"gsd-core/workflows/health.md": "551e63aa6f3df711",
|
||||
"gsd-core/workflows/help.md": "5d040504b9ab35e3",
|
||||
"gsd-core/workflows/help/modes/brief.md": "fa2675516b40e2e3",
|
||||
"gsd-core/workflows/help/modes/default.md": "be05e56b2c5ee2c0",
|
||||
"gsd-core/workflows/help/modes/full.md": "ce40e843f528e327",
|
||||
"gsd-core/workflows/help/modes/topic.md": "6e42db16f1568be9",
|
||||
"gsd-core/workflows/import.md": "cc21f3da36403ed3",
|
||||
"gsd-core/workflows/inbox.md": "91aac6360e1a8672",
|
||||
"gsd-core/workflows/ingest-docs.md": "1d42ea1be30becc4",
|
||||
"gsd-core/workflows/insert-phase.md": "ae977afd1509dc86",
|
||||
"gsd-core/workflows/list-phase-assumptions.md": "2a6b6a5acfb7742c",
|
||||
"gsd-core/workflows/list-seeds.md": "7576156b91e9abee",
|
||||
"gsd-core/workflows/list-workspaces.md": "90caeeb11cec0128",
|
||||
"gsd-core/workflows/manager.md": "36559ff897e55c09",
|
||||
"gsd-core/workflows/map-codebase.md": "b11ca99a885e93ef",
|
||||
"gsd-core/workflows/milestone-summary.md": "99636900c216c8d2",
|
||||
"gsd-core/workflows/mvp-phase.md": "254baac57e85dca7",
|
||||
"gsd-core/workflows/new-milestone.md": "fcb63a8b13c02d6e",
|
||||
"gsd-core/workflows/new-project.md": "26907d3cf3630ed0",
|
||||
"gsd-core/workflows/new-workspace.md": "26615bf710f0a324",
|
||||
"gsd-core/workflows/next.md": "1193222c5618d3db",
|
||||
"gsd-core/workflows/node-repair.md": "07a1628e5a1ff96b",
|
||||
"gsd-core/workflows/note.md": "a2cc926854a5666c",
|
||||
"gsd-core/workflows/onboard.md": "b86d78eef6c77e5b",
|
||||
"gsd-core/workflows/pause-work.md": "da902807d2213204",
|
||||
"gsd-core/workflows/plan-milestone-gaps.md": "7679fac068d1009d",
|
||||
"gsd-core/workflows/plan-phase.md": "8e8331ca99bc8680",
|
||||
"gsd-core/workflows/plan-phase/steps/closed-phase-gate.md": "4099ef6d0868de60",
|
||||
"gsd-core/workflows/plan-phase/steps/prd-express-path.md": "b810f9f2374e23a5",
|
||||
"gsd-core/workflows/plan-phase/steps/windows-troubleshooting.md": "e9de7a96bbfff261",
|
||||
"gsd-core/workflows/plan-review-convergence.md": "1cabea77790fc16c",
|
||||
"gsd-core/workflows/plant-seed.md": "fbe964fcdb244802",
|
||||
"gsd-core/workflows/pr-branch.md": "513f6cff722eff2d",
|
||||
"gsd-core/workflows/profile-user.md": "3b34dcb337d50f4b",
|
||||
"gsd-core/workflows/progress.md": "2be3a57916eccf87",
|
||||
"gsd-core/workflows/quick.md": "20f9dbfcd20b4b4b",
|
||||
"gsd-core/workflows/reapply-patches.md": "44a96b52b975e9bb",
|
||||
"gsd-core/workflows/remove-phase.md": "8effc8742d58a11a",
|
||||
"gsd-core/workflows/remove-workspace.md": "10882656198d9075",
|
||||
"gsd-core/workflows/resume-project.md": "af9761bcec0f6fe9",
|
||||
"gsd-core/workflows/review.md": "f9b63577ff23c2a9",
|
||||
"gsd-core/workflows/scan.md": "75c670d08cee8680",
|
||||
"gsd-core/workflows/secure-phase.md": "64ec4d06ca85720a",
|
||||
"gsd-core/workflows/session-report.md": "2e5b1205324ddefa",
|
||||
"gsd-core/workflows/settings-advanced.md": "2fc2738442f4f9de",
|
||||
"gsd-core/workflows/settings-integrations.md": "dfe3672c4fabf139",
|
||||
"gsd-core/workflows/settings.md": "ba138b058d91fd38",
|
||||
"gsd-core/workflows/ship.md": "44af1c72d86e153b",
|
||||
"gsd-core/workflows/sketch-wrap-up.md": "d52a5462bafda830",
|
||||
"gsd-core/workflows/sketch.md": "dbe6acc4d976060c",
|
||||
"gsd-core/workflows/smart-entry.md": "1850447c045f36d8",
|
||||
"gsd-core/workflows/spec-phase.md": "e03fa9f1a44613dc",
|
||||
"gsd-core/workflows/spike-wrap-up.md": "4bdfaf9d05c63e7c",
|
||||
"gsd-core/workflows/spike.md": "1571a05457beea8d",
|
||||
"gsd-core/workflows/stats.md": "3953356f476b5053",
|
||||
"gsd-core/workflows/sync-skills.md": "5624d529dae1ad79",
|
||||
"gsd-core/workflows/thread.md": "14a9d195572a198f",
|
||||
"gsd-core/workflows/transition.md": "78a91b0154a93cf5",
|
||||
"gsd-core/workflows/ui-phase.md": "57664a12509b455d",
|
||||
"gsd-core/workflows/ui-review.md": "9c6005236e2067b5",
|
||||
"gsd-core/workflows/ultraplan-phase.md": "b926ba7e4de0c76d",
|
||||
"gsd-core/workflows/undo.md": "d759702f84e308fa",
|
||||
"gsd-core/workflows/update.md": "2a59b4edf4a3c8c7",
|
||||
"gsd-core/workflows/validate-phase.md": "83eeefeeca31c2b5",
|
||||
"gsd-core/workflows/verify-phase.md": "6ae6f159be75dcdd",
|
||||
"gsd-core/workflows/verify-work.md": "de14acdc8e925338",
|
||||
"hooks/gsd-check-update-worker.js": "a530efdb5fdc0da3",
|
||||
"hooks/gsd-check-update.js": "25cde66a12d6b886",
|
||||
"hooks/gsd-config-reload.js": "96546e0e8bb47904",
|
||||
"hooks/gsd-context-monitor.js": "ecbe9747e4a442e0",
|
||||
"hooks/gsd-cursor-post-tool.js": "8a8a249c0642cc71",
|
||||
"hooks/gsd-cursor-pre-tool.js": "8cb8e8f895edaec9",
|
||||
"hooks/gsd-cursor-session-start.js": "05a14e903c5edafa",
|
||||
"hooks/gsd-cursor-stop.js": "d33be8ac96f4081d",
|
||||
"hooks/gsd-cursor-subagent-start.js": "d773df8caa605de2",
|
||||
"hooks/gsd-cursor-subagent-stop.js": "8ee488d826bf3c37",
|
||||
"hooks/gsd-ensure-canonical-path.js": "b4b3b88a0e493b16",
|
||||
"hooks/gsd-graphify-update.sh": "e4c6e14fe6ad64ff",
|
||||
"hooks/gsd-phase-boundary.sh": "32739d5fbe0d0a1c",
|
||||
"hooks/gsd-prompt-guard.js": "a749b8cb2c5248de",
|
||||
"hooks/gsd-read-guard.js": "9e423cd03e2d1b16",
|
||||
"hooks/gsd-read-injection-scanner.js": "00d2449afefd2e5f",
|
||||
"hooks/gsd-session-state.sh": "e54379ba86bf1b6d",
|
||||
"hooks/gsd-statusline.js": "7c315416ffc99a9a",
|
||||
"hooks/gsd-update-banner.js": "b457746cb76c1957",
|
||||
"hooks/gsd-validate-commit.sh": "bf5dd61d33cb3a38",
|
||||
"hooks/gsd-workflow-guard.js": "59b46a74d19d58d3",
|
||||
"hooks/gsd-worktree-path-guard.js": "02be1bb504b22eb5",
|
||||
"hooks/lib/git-cmd.js": "268ba15992ca0b23",
|
||||
"hooks/lib/gsd-graphify-rebuild.sh": "66af89601074d2a9",
|
||||
"hooks/managed-hooks-registry.cjs": "f2e325aa9ba31647",
|
||||
"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/gen-capability-registry.cjs": "c52201ff4d1c2cd7",
|
||||
"scripts/gen-loop-host-contract.cjs": "c7f15237234811a0",
|
||||
"scripts/lib/allowlist-ratchet.cjs": "ffaceaac3efc2660",
|
||||
"scripts/lib/cli-exit.cjs": "612d0c372c75b7e7"
|
||||
}
|
||||
@@ -283,7 +283,7 @@
|
||||
"gsd-core/workflows/remove-phase.md": "ada8a0546c686483",
|
||||
"gsd-core/workflows/remove-workspace.md": "f3ab3a88a7e9e1ed",
|
||||
"gsd-core/workflows/resume-project.md": "7f8dc986f0f35d96",
|
||||
"gsd-core/workflows/review.md": "5cd71e81c4ace401",
|
||||
"gsd-core/workflows/review.md": "2dbfb1118613be25",
|
||||
"gsd-core/workflows/scan.md": "47371c2073d6c0be",
|
||||
"gsd-core/workflows/secure-phase.md": "59d3c50aba8c9a6c",
|
||||
"gsd-core/workflows/session-report.md": "2e5b1205324ddefa",
|
||||
@@ -314,7 +314,11 @@
|
||||
"hooks/gsd-config-reload.js": "96546e0e8bb47904",
|
||||
"hooks/gsd-context-monitor.js": "ecbe9747e4a442e0",
|
||||
"hooks/gsd-cursor-post-tool.js": "8a8a249c0642cc71",
|
||||
"hooks/gsd-cursor-pre-tool.js": "8cb8e8f895edaec9",
|
||||
"hooks/gsd-cursor-session-start.js": "05a14e903c5edafa",
|
||||
"hooks/gsd-cursor-stop.js": "d33be8ac96f4081d",
|
||||
"hooks/gsd-cursor-subagent-start.js": "d773df8caa605de2",
|
||||
"hooks/gsd-cursor-subagent-stop.js": "8ee488d826bf3c37",
|
||||
"hooks/gsd-ensure-canonical-path.js": "b4b3b88a0e493b16",
|
||||
"hooks/gsd-graphify-update.sh": "e4c6e14fe6ad64ff",
|
||||
"hooks/gsd-phase-boundary.sh": "32739d5fbe0d0a1c",
|
||||
@@ -329,7 +333,7 @@
|
||||
"hooks/gsd-worktree-path-guard.js": "02be1bb504b22eb5",
|
||||
"hooks/lib/git-cmd.js": "268ba15992ca0b23",
|
||||
"hooks/lib/gsd-graphify-rebuild.sh": "66af89601074d2a9",
|
||||
"hooks/managed-hooks-registry.cjs": "ea876b1ec185173e",
|
||||
"hooks/managed-hooks-registry.cjs": "f2e325aa9ba31647",
|
||||
"package.json": "dbf8353f77358bc1",
|
||||
"scripts/changeset/README.md": "86ff89331dfd94b2",
|
||||
"scripts/changeset/cli.cjs": "68f92a344b199271",
|
||||
|
||||
@@ -287,7 +287,7 @@
|
||||
"gsd-core/workflows/remove-phase.md": "e336350f8113a328",
|
||||
"gsd-core/workflows/remove-workspace.md": "e685dfbd736dfd90",
|
||||
"gsd-core/workflows/resume-project.md": "e23981178fa37b3d",
|
||||
"gsd-core/workflows/review.md": "c2b3fcc5bfc80038",
|
||||
"gsd-core/workflows/review.md": "86d03cb58f48f45b",
|
||||
"gsd-core/workflows/scan.md": "dfd92717caea0ce7",
|
||||
"gsd-core/workflows/secure-phase.md": "cf78183f06a02582",
|
||||
"gsd-core/workflows/session-report.md": "2e5b1205324ddefa",
|
||||
|
||||
@@ -355,7 +355,7 @@
|
||||
"gsd-core/workflows/remove-phase.md": "df9a45f0b1880999",
|
||||
"gsd-core/workflows/remove-workspace.md": "a7ca66db6b7c132c",
|
||||
"gsd-core/workflows/resume-project.md": "f28da1200e4545f4",
|
||||
"gsd-core/workflows/review.md": "f3c2e894941c9943",
|
||||
"gsd-core/workflows/review.md": "93fe46bd0b5dd3d1",
|
||||
"gsd-core/workflows/scan.md": "003883d71c37da7d",
|
||||
"gsd-core/workflows/secure-phase.md": "29fc6b62c5c5dc62",
|
||||
"gsd-core/workflows/session-report.md": "2e5b1205324ddefa",
|
||||
@@ -386,7 +386,11 @@
|
||||
"hooks/gsd-config-reload.js": "96546e0e8bb47904",
|
||||
"hooks/gsd-context-monitor.js": "f372804867cabe40",
|
||||
"hooks/gsd-cursor-post-tool.js": "9168e0a09de1972a",
|
||||
"hooks/gsd-cursor-pre-tool.js": "873998b25e308c29",
|
||||
"hooks/gsd-cursor-session-start.js": "9b2e6f4f0c405375",
|
||||
"hooks/gsd-cursor-stop.js": "bfaaf60f419e3238",
|
||||
"hooks/gsd-cursor-subagent-start.js": "06d77fde5c1372b6",
|
||||
"hooks/gsd-cursor-subagent-stop.js": "4bbf22917da4d389",
|
||||
"hooks/gsd-ensure-canonical-path.js": "434887487ae63ec5",
|
||||
"hooks/gsd-graphify-update.sh": "e4c6e14fe6ad64ff",
|
||||
"hooks/gsd-phase-boundary.sh": "32739d5fbe0d0a1c",
|
||||
@@ -401,7 +405,7 @@
|
||||
"hooks/gsd-worktree-path-guard.js": "548fc57131a04fa7",
|
||||
"hooks/lib/git-cmd.js": "268ba15992ca0b23",
|
||||
"hooks/lib/gsd-graphify-rebuild.sh": "66af89601074d2a9",
|
||||
"hooks/managed-hooks-registry.cjs": "0368fd4ac7bb3d1d",
|
||||
"hooks/managed-hooks-registry.cjs": "0e7a61bde8688e11",
|
||||
"package.json": "dbf8353f77358bc1",
|
||||
"scripts/changeset/README.md": "86ff89331dfd94b2",
|
||||
"scripts/changeset/cli.cjs": "68f92a344b199271",
|
||||
|
||||
148
tests/fixtures/golden-install-parity/codex.json
vendored
148
tests/fixtures/golden-install-parity/codex.json
vendored
@@ -1,4 +1,75 @@
|
||||
{
|
||||
".agents/skills/gsd-add-tests/SKILL.md": "f3c1594a059de3ee",
|
||||
".agents/skills/gsd-ai-integration-phase/SKILL.md": "ba4b5f406db6de96",
|
||||
".agents/skills/gsd-audit-fix/SKILL.md": "9cea2764b92aa352",
|
||||
".agents/skills/gsd-audit-milestone/SKILL.md": "0528ef8a5834b5df",
|
||||
".agents/skills/gsd-audit-uat/SKILL.md": "6c2aa0c2b62c5233",
|
||||
".agents/skills/gsd-autonomous/SKILL.md": "d56dc692750f4926",
|
||||
".agents/skills/gsd-capture/SKILL.md": "211d601d122b5dd6",
|
||||
".agents/skills/gsd-cleanup/SKILL.md": "18cba17463c6ef97",
|
||||
".agents/skills/gsd-code-review/SKILL.md": "74749a1aff4224d7",
|
||||
".agents/skills/gsd-complete-milestone/SKILL.md": "77bee8741f8cb381",
|
||||
".agents/skills/gsd-config/SKILL.md": "5fde92e30619edcb",
|
||||
".agents/skills/gsd-debug/SKILL.md": "7e56964e14fd0a10",
|
||||
".agents/skills/gsd-discuss-phase/SKILL.md": "837d46c55cc2424e",
|
||||
".agents/skills/gsd-docs-update/SKILL.md": "5028dc5dd44bee7d",
|
||||
".agents/skills/gsd-eval-review/SKILL.md": "a300e78a27ba74d6",
|
||||
".agents/skills/gsd-execute-phase/SKILL.md": "e77bee13068600d3",
|
||||
".agents/skills/gsd-explore/SKILL.md": "d660cde0e6f31ebf",
|
||||
".agents/skills/gsd-extract-learnings/SKILL.md": "e32388999da384e4",
|
||||
".agents/skills/gsd-fast/SKILL.md": "14c407a76a40e115",
|
||||
".agents/skills/gsd-forensics/SKILL.md": "6d496a16f5ca74b3",
|
||||
".agents/skills/gsd-graphify/SKILL.md": "1bbd96129fbacc5c",
|
||||
".agents/skills/gsd-health/SKILL.md": "656c9c63852e3787",
|
||||
".agents/skills/gsd-help/SKILL.md": "28eb8d0b487239f8",
|
||||
".agents/skills/gsd-import/SKILL.md": "4d15ee09e531342f",
|
||||
".agents/skills/gsd-inbox/SKILL.md": "1315033595643485",
|
||||
".agents/skills/gsd-ingest-docs/SKILL.md": "5bd2838bf0b6dc1b",
|
||||
".agents/skills/gsd-manager/SKILL.md": "cb1cf56f5d3f66d6",
|
||||
".agents/skills/gsd-map-codebase/SKILL.md": "cd27dc028718426b",
|
||||
".agents/skills/gsd-mempalace-capture/SKILL.md": "1a7fac4d53f607ab",
|
||||
".agents/skills/gsd-mempalace-recall/SKILL.md": "7205b02250e89f25",
|
||||
".agents/skills/gsd-milestone-summary/SKILL.md": "af84ecb400f23556",
|
||||
".agents/skills/gsd-mvp-phase/SKILL.md": "72b65ae927b280fe",
|
||||
".agents/skills/gsd-new-milestone/SKILL.md": "587574d2d475bb10",
|
||||
".agents/skills/gsd-new-project/SKILL.md": "129ac16e6a1f04ba",
|
||||
".agents/skills/gsd-next/SKILL.md": "7855fd70e387087e",
|
||||
".agents/skills/gsd-ns-context/SKILL.md": "9b496da79789b3f9",
|
||||
".agents/skills/gsd-ns-ideate/SKILL.md": "84ca1cde06110981",
|
||||
".agents/skills/gsd-ns-manage/SKILL.md": "909dafa0cd19ba5a",
|
||||
".agents/skills/gsd-ns-project/SKILL.md": "936b6998d42520ac",
|
||||
".agents/skills/gsd-ns-review/SKILL.md": "022253010db0e072",
|
||||
".agents/skills/gsd-ns-workflow/SKILL.md": "cba075bc819b539e",
|
||||
".agents/skills/gsd-onboard/SKILL.md": "f42fc2edebeda671",
|
||||
".agents/skills/gsd-pause-work/SKILL.md": "b379469eed78a196",
|
||||
".agents/skills/gsd-phase/SKILL.md": "25477edc97a90c91",
|
||||
".agents/skills/gsd-plan-phase/SKILL.md": "19ead1acb151a868",
|
||||
".agents/skills/gsd-plan-review-convergence/SKILL.md": "f654089c11024027",
|
||||
".agents/skills/gsd-pr-branch/SKILL.md": "6901da15e321913e",
|
||||
".agents/skills/gsd-profile-user/SKILL.md": "6259fabfb6afe7be",
|
||||
".agents/skills/gsd-progress/SKILL.md": "85d76286162b9189",
|
||||
".agents/skills/gsd-quick/SKILL.md": "b4f4e711ba664aa0",
|
||||
".agents/skills/gsd-resume-work/SKILL.md": "04f6c2e5b579e8c4",
|
||||
".agents/skills/gsd-review-backlog/SKILL.md": "469696b944c4e7b5",
|
||||
".agents/skills/gsd-review/SKILL.md": "a45ab2fdca06793b",
|
||||
".agents/skills/gsd-secure-phase/SKILL.md": "e64ad269c1e6e319",
|
||||
".agents/skills/gsd-settings/SKILL.md": "fcdda8dd545622ae",
|
||||
".agents/skills/gsd-ship/SKILL.md": "9615cc4c8f6de060",
|
||||
".agents/skills/gsd-sketch/SKILL.md": "90c219b843db7ec7",
|
||||
".agents/skills/gsd-spec-phase/SKILL.md": "56a5cbd606db9cba",
|
||||
".agents/skills/gsd-spike/SKILL.md": "77209fef7a04c11a",
|
||||
".agents/skills/gsd-stats/SKILL.md": "566024444ef71f44",
|
||||
".agents/skills/gsd-surface/SKILL.md": "492cda98187a969b",
|
||||
".agents/skills/gsd-thread/SKILL.md": "85326c97c83a02d1",
|
||||
".agents/skills/gsd-ui-phase/SKILL.md": "a0c8fbcbe5e3c2b9",
|
||||
".agents/skills/gsd-ui-review/SKILL.md": "081a3292a2d357f5",
|
||||
".agents/skills/gsd-ultraplan-phase/SKILL.md": "06fb3d76eb785f94",
|
||||
".agents/skills/gsd-undo/SKILL.md": "007d3b307e027af9",
|
||||
".agents/skills/gsd-update/SKILL.md": "e6e49e117c7c8f35",
|
||||
".agents/skills/gsd-validate-phase/SKILL.md": "373059517a94a194",
|
||||
".agents/skills/gsd-verify-work/SKILL.md": "4272275698e9a3fe",
|
||||
".agents/skills/gsd-workspace/SKILL.md": "06d6400d68318361",
|
||||
".agents/skills/gsd-workstreams/SKILL.md": "2f77bb94db1be1a4",
|
||||
".gsd-profile": "0e716a5fef4e6dc1",
|
||||
".gsd/defaults.json": "560664b045e645cb",
|
||||
"agents/gsd-advisor-researcher.md": "eea6d1604aaf305c",
|
||||
@@ -69,7 +140,7 @@
|
||||
"agents/gsd-user-profiler.toml": "b9c244bb8fbf8140",
|
||||
"agents/gsd-verifier.md": "4ac4b860e2504374",
|
||||
"agents/gsd-verifier.toml": "8ed9fb961409e894",
|
||||
"config.toml": "a34316b9ac61a620",
|
||||
"config.toml": "fa48d84b92174890",
|
||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62",
|
||||
@@ -319,7 +390,7 @@
|
||||
"gsd-core/workflows/remove-phase.md": "9ee0fddd11a0d9d4",
|
||||
"gsd-core/workflows/remove-workspace.md": "19d7465aaa50cb62",
|
||||
"gsd-core/workflows/resume-project.md": "9965f87eb278f7f8",
|
||||
"gsd-core/workflows/review.md": "3a086e38f13eab6f",
|
||||
"gsd-core/workflows/review.md": "63c61eddf20d77f1",
|
||||
"gsd-core/workflows/scan.md": "1a3caa5d724d39e9",
|
||||
"gsd-core/workflows/secure-phase.md": "db91810d16964b1e",
|
||||
"gsd-core/workflows/session-report.md": "dd8fa011c9394075",
|
||||
@@ -359,76 +430,5 @@
|
||||
"scripts/gen-capability-registry.cjs": "c52201ff4d1c2cd7",
|
||||
"scripts/gen-loop-host-contract.cjs": "c7f15237234811a0",
|
||||
"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": "cd27dc028718426b",
|
||||
"skills/gsd-mempalace-capture/SKILL.md": "1a7fac4d53f607ab",
|
||||
"skills/gsd-mempalace-recall/SKILL.md": "7205b02250e89f25",
|
||||
"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-next/SKILL.md": "7855fd70e387087e",
|
||||
"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": "936b6998d42520ac",
|
||||
"skills/gsd-ns-review/SKILL.md": "022253010db0e072",
|
||||
"skills/gsd-ns-workflow/SKILL.md": "cba075bc819b539e",
|
||||
"skills/gsd-onboard/SKILL.md": "f42fc2edebeda671",
|
||||
"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": "a45ab2fdca06793b",
|
||||
"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"
|
||||
"scripts/lib/cli-exit.cjs": "612d0c372c75b7e7"
|
||||
}
|
||||
|
||||
@@ -285,7 +285,7 @@
|
||||
"gsd-core/workflows/remove-phase.md": "e262654e319d1bc4",
|
||||
"gsd-core/workflows/remove-workspace.md": "ceddfeef5f2d6754",
|
||||
"gsd-core/workflows/resume-project.md": "40db7f350f5866d8",
|
||||
"gsd-core/workflows/review.md": "01b40d7ddd69dc8b",
|
||||
"gsd-core/workflows/review.md": "5f146ed397bcfe5a",
|
||||
"gsd-core/workflows/scan.md": "dcc2f76d0850e2fb",
|
||||
"gsd-core/workflows/secure-phase.md": "d87bd706f85bcad6",
|
||||
"gsd-core/workflows/session-report.md": "2e5b1205324ddefa",
|
||||
|
||||
@@ -355,7 +355,7 @@
|
||||
"gsd-core/workflows/remove-phase.md": "ada8a0546c686483",
|
||||
"gsd-core/workflows/remove-workspace.md": "433affcd1a200826",
|
||||
"gsd-core/workflows/resume-project.md": "7f8dc986f0f35d96",
|
||||
"gsd-core/workflows/review.md": "338005f1da182bdc",
|
||||
"gsd-core/workflows/review.md": "ed6b9f54f74204ff",
|
||||
"gsd-core/workflows/scan.md": "47371c2073d6c0be",
|
||||
"gsd-core/workflows/secure-phase.md": "c55975672c4e1895",
|
||||
"gsd-core/workflows/session-report.md": "2e5b1205324ddefa",
|
||||
@@ -382,7 +382,11 @@
|
||||
"gsd-core/workflows/verify-phase.md": "e0957e153788a222",
|
||||
"gsd-core/workflows/verify-work.md": "e7e7e900c4874490",
|
||||
"hooks/gsd-cursor-post-tool.js": "019d503aee8b4a3f",
|
||||
"hooks/gsd-cursor-pre-tool.js": "fe274720781fcbb5",
|
||||
"hooks/gsd-cursor-session-start.js": "c6e04ed597ea7020",
|
||||
"hooks/gsd-cursor-stop.js": "902a005c49e7660b",
|
||||
"hooks/gsd-cursor-subagent-start.js": "693d38d298d2252c",
|
||||
"hooks/gsd-cursor-subagent-stop.js": "8da03aad6bb05d3f",
|
||||
"scripts/changeset/README.md": "86ff89331dfd94b2",
|
||||
"scripts/changeset/cli.cjs": "68f92a344b199271",
|
||||
"scripts/changeset/github-release-notes.cjs": "795677f0c009b132",
|
||||
|
||||
@@ -284,7 +284,7 @@
|
||||
"gsd-core/workflows/remove-phase.md": "fce799aae3ab2715",
|
||||
"gsd-core/workflows/remove-workspace.md": "8facde381657dd71",
|
||||
"gsd-core/workflows/resume-project.md": "a0443839f1f83c2d",
|
||||
"gsd-core/workflows/review.md": "f11ffe5b46b50565",
|
||||
"gsd-core/workflows/review.md": "c9de3987db14b94f",
|
||||
"gsd-core/workflows/scan.md": "b28f65d88c522767",
|
||||
"gsd-core/workflows/secure-phase.md": "f2957d4b88fb3746",
|
||||
"gsd-core/workflows/session-report.md": "2e5b1205324ddefa",
|
||||
@@ -315,7 +315,11 @@
|
||||
"hooks/gsd-config-reload.js": "880b696458e85e9b",
|
||||
"hooks/gsd-context-monitor.js": "41d28e0db7b20968",
|
||||
"hooks/gsd-cursor-post-tool.js": "8a8a249c0642cc71",
|
||||
"hooks/gsd-cursor-pre-tool.js": "8cb8e8f895edaec9",
|
||||
"hooks/gsd-cursor-session-start.js": "05a14e903c5edafa",
|
||||
"hooks/gsd-cursor-stop.js": "d33be8ac96f4081d",
|
||||
"hooks/gsd-cursor-subagent-start.js": "d773df8caa605de2",
|
||||
"hooks/gsd-cursor-subagent-stop.js": "8ee488d826bf3c37",
|
||||
"hooks/gsd-ensure-canonical-path.js": "7d116d7d65c50b4b",
|
||||
"hooks/gsd-graphify-update.sh": "e4c6e14fe6ad64ff",
|
||||
"hooks/gsd-phase-boundary.sh": "32739d5fbe0d0a1c",
|
||||
@@ -330,7 +334,7 @@
|
||||
"hooks/gsd-worktree-path-guard.js": "108ab88ccbafc5d8",
|
||||
"hooks/lib/git-cmd.js": "268ba15992ca0b23",
|
||||
"hooks/lib/gsd-graphify-rebuild.sh": "66af89601074d2a9",
|
||||
"hooks/managed-hooks-registry.cjs": "2218a41c279720c2",
|
||||
"hooks/managed-hooks-registry.cjs": "a494d1a70ed87690",
|
||||
"package.json": "dbf8353f77358bc1",
|
||||
"scripts/changeset/README.md": "86ff89331dfd94b2",
|
||||
"scripts/changeset/cli.cjs": "68f92a344b199271",
|
||||
|
||||
@@ -355,7 +355,7 @@
|
||||
"gsd-core/workflows/remove-phase.md": "ada8a0546c686483",
|
||||
"gsd-core/workflows/remove-workspace.md": "fc83f362a2d0a1b7",
|
||||
"gsd-core/workflows/resume-project.md": "7f8dc986f0f35d96",
|
||||
"gsd-core/workflows/review.md": "3af2d510da581502",
|
||||
"gsd-core/workflows/review.md": "dcea2ffd4f54c845",
|
||||
"gsd-core/workflows/scan.md": "47371c2073d6c0be",
|
||||
"gsd-core/workflows/secure-phase.md": "e8855104c1e0417c",
|
||||
"gsd-core/workflows/session-report.md": "2e5b1205324ddefa",
|
||||
|
||||
@@ -320,7 +320,7 @@
|
||||
"gsd-core/workflows/remove-phase.md": "df9a45f0b1880999",
|
||||
"gsd-core/workflows/remove-workspace.md": "a7ca66db6b7c132c",
|
||||
"gsd-core/workflows/resume-project.md": "f28da1200e4545f4",
|
||||
"gsd-core/workflows/review.md": "f3c2e894941c9943",
|
||||
"gsd-core/workflows/review.md": "93fe46bd0b5dd3d1",
|
||||
"gsd-core/workflows/scan.md": "003883d71c37da7d",
|
||||
"gsd-core/workflows/secure-phase.md": "29fc6b62c5c5dc62",
|
||||
"gsd-core/workflows/session-report.md": "2e5b1205324ddefa",
|
||||
|
||||
@@ -355,7 +355,7 @@
|
||||
"gsd-core/workflows/remove-phase.md": "dea4661e8f89596f",
|
||||
"gsd-core/workflows/remove-workspace.md": "446847e71aa52504",
|
||||
"gsd-core/workflows/resume-project.md": "ad9f06a10bab8cc0",
|
||||
"gsd-core/workflows/review.md": "94ff56aecbbe2753",
|
||||
"gsd-core/workflows/review.md": "a039f632e0b89003",
|
||||
"gsd-core/workflows/scan.md": "ad8ebcad4626d4a8",
|
||||
"gsd-core/workflows/secure-phase.md": "e9a488cec3b4efdc",
|
||||
"gsd-core/workflows/session-report.md": "2e5b1205324ddefa",
|
||||
@@ -386,7 +386,11 @@
|
||||
"hooks/gsd-config-reload.js": "96546e0e8bb47904",
|
||||
"hooks/gsd-context-monitor.js": "7a9787868a39b76d",
|
||||
"hooks/gsd-cursor-post-tool.js": "9168e0a09de1972a",
|
||||
"hooks/gsd-cursor-pre-tool.js": "873998b25e308c29",
|
||||
"hooks/gsd-cursor-session-start.js": "9b2e6f4f0c405375",
|
||||
"hooks/gsd-cursor-stop.js": "bfaaf60f419e3238",
|
||||
"hooks/gsd-cursor-subagent-start.js": "06d77fde5c1372b6",
|
||||
"hooks/gsd-cursor-subagent-stop.js": "4bbf22917da4d389",
|
||||
"hooks/gsd-ensure-canonical-path.js": "2801ae3fef9579bf",
|
||||
"hooks/gsd-graphify-update.sh": "e4c6e14fe6ad64ff",
|
||||
"hooks/gsd-phase-boundary.sh": "32739d5fbe0d0a1c",
|
||||
@@ -401,10 +405,10 @@
|
||||
"hooks/gsd-worktree-path-guard.js": "726fb9afefda5d42",
|
||||
"hooks/lib/git-cmd.js": "268ba15992ca0b23",
|
||||
"hooks/lib/gsd-graphify-rebuild.sh": "66af89601074d2a9",
|
||||
"hooks/managed-hooks-registry.cjs": "763730ef31e5fd1c",
|
||||
"hooks/managed-hooks-registry.cjs": "9163e096b74ec4b3",
|
||||
"opencode.json": "2c12c446a88f2f36",
|
||||
"package.json": "dbf8353f77358bc1",
|
||||
"plugins/gsd-core.js": "63687dc233ca707e",
|
||||
"plugins/gsd-core.js": "931ca839dc9eb7f1",
|
||||
"scripts/changeset/README.md": "86ff89331dfd94b2",
|
||||
"scripts/changeset/cli.cjs": "68f92a344b199271",
|
||||
"scripts/changeset/github-release-notes.cjs": "795677f0c009b132",
|
||||
|
||||
@@ -284,7 +284,7 @@
|
||||
"gsd-core/workflows/remove-phase.md": "e8ae4fbbfac700f0",
|
||||
"gsd-core/workflows/remove-workspace.md": "4ac64de862dc650e",
|
||||
"gsd-core/workflows/resume-project.md": "7f20769f302e5427",
|
||||
"gsd-core/workflows/review.md": "bb5c36262d5ff951",
|
||||
"gsd-core/workflows/review.md": "aa4674a4754438f2",
|
||||
"gsd-core/workflows/scan.md": "949692db4834dd27",
|
||||
"gsd-core/workflows/secure-phase.md": "6758f1acf4113e9e",
|
||||
"gsd-core/workflows/session-report.md": "2e5b1205324ddefa",
|
||||
@@ -315,7 +315,11 @@
|
||||
"hooks/gsd-config-reload.js": "4f52b8a0120bb1b8",
|
||||
"hooks/gsd-context-monitor.js": "437a33e6e3058640",
|
||||
"hooks/gsd-cursor-post-tool.js": "8a8a249c0642cc71",
|
||||
"hooks/gsd-cursor-pre-tool.js": "8cb8e8f895edaec9",
|
||||
"hooks/gsd-cursor-session-start.js": "05a14e903c5edafa",
|
||||
"hooks/gsd-cursor-stop.js": "d33be8ac96f4081d",
|
||||
"hooks/gsd-cursor-subagent-start.js": "d773df8caa605de2",
|
||||
"hooks/gsd-cursor-subagent-stop.js": "8ee488d826bf3c37",
|
||||
"hooks/gsd-ensure-canonical-path.js": "2df5e295b36c3334",
|
||||
"hooks/gsd-graphify-update.sh": "e4c6e14fe6ad64ff",
|
||||
"hooks/gsd-phase-boundary.sh": "32739d5fbe0d0a1c",
|
||||
@@ -330,7 +334,7 @@
|
||||
"hooks/gsd-worktree-path-guard.js": "8389e4c9175b2613",
|
||||
"hooks/lib/git-cmd.js": "268ba15992ca0b23",
|
||||
"hooks/lib/gsd-graphify-rebuild.sh": "66af89601074d2a9",
|
||||
"hooks/managed-hooks-registry.cjs": "a57697c1ae4ac163",
|
||||
"hooks/managed-hooks-registry.cjs": "a5a93d50c4ea7a0c",
|
||||
"package.json": "dbf8353f77358bc1",
|
||||
"scripts/changeset/README.md": "86ff89331dfd94b2",
|
||||
"scripts/changeset/cli.cjs": "68f92a344b199271",
|
||||
|
||||
@@ -284,7 +284,7 @@
|
||||
"gsd-core/workflows/remove-phase.md": "a46c2fe853bf4e86",
|
||||
"gsd-core/workflows/remove-workspace.md": "ae0e1c6d4438d663",
|
||||
"gsd-core/workflows/resume-project.md": "f242e4c8aba18ea2",
|
||||
"gsd-core/workflows/review.md": "34a23b9b65fb55f6",
|
||||
"gsd-core/workflows/review.md": "51eb79b72a09d47a",
|
||||
"gsd-core/workflows/scan.md": "63631467651d9ca8",
|
||||
"gsd-core/workflows/secure-phase.md": "6cc236e53c2e7d56",
|
||||
"gsd-core/workflows/session-report.md": "2e5b1205324ddefa",
|
||||
|
||||
@@ -284,7 +284,7 @@
|
||||
"gsd-core/workflows/remove-phase.md": "e7a6af429b36e77b",
|
||||
"gsd-core/workflows/remove-workspace.md": "b5e60fbb33b3e33a",
|
||||
"gsd-core/workflows/resume-project.md": "82cfe1b8cb17c085",
|
||||
"gsd-core/workflows/review.md": "0873ea94ca23383c",
|
||||
"gsd-core/workflows/review.md": "31b1f08d08b8ccdc",
|
||||
"gsd-core/workflows/scan.md": "12c11b2edc165df9",
|
||||
"gsd-core/workflows/secure-phase.md": "7bf923689bf58288",
|
||||
"gsd-core/workflows/session-report.md": "2e5b1205324ddefa",
|
||||
|
||||
@@ -355,7 +355,7 @@
|
||||
"gsd-core/workflows/remove-phase.md": "df9a45f0b1880999",
|
||||
"gsd-core/workflows/remove-workspace.md": "a7ca66db6b7c132c",
|
||||
"gsd-core/workflows/resume-project.md": "f28da1200e4545f4",
|
||||
"gsd-core/workflows/review.md": "f3c2e894941c9943",
|
||||
"gsd-core/workflows/review.md": "93fe46bd0b5dd3d1",
|
||||
"gsd-core/workflows/scan.md": "003883d71c37da7d",
|
||||
"gsd-core/workflows/secure-phase.md": "29fc6b62c5c5dc62",
|
||||
"gsd-core/workflows/session-report.md": "2e5b1205324ddefa",
|
||||
|
||||
@@ -80,7 +80,9 @@ const PKG_VERSION = require('../package.json').version;
|
||||
// (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']);
|
||||
// settings.local.json = Claude LOCAL hook surface (#338): same platform-varying
|
||||
// node-runner command as settings.json, so excluded for the same reason (#2086).
|
||||
const HOOK_CONFIG_FILES = new Set(['settings.json', 'settings.local.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
|
||||
@@ -111,6 +113,17 @@ function buildParityManifest(configDir, root) {
|
||||
const allFiles = walk(configDir);
|
||||
const unsorted = {};
|
||||
|
||||
// The claude LOCAL install resolves its config dir via realpath, which on macOS
|
||||
// prepends `/private` to the temp root (`/var/folders/…` -> `/private/var/folders/…`)
|
||||
// and embeds that resolved path in the projected agents/commands/workflows (`@…`
|
||||
// references). On Linux the temp root has no `/private` symlink, so normalizing
|
||||
// ONLY `root` left the `/private` prefix on macOS and produced platform-divergent
|
||||
// hashes (#2086). Normalize the realpath form FIRST (it is the longer, `/private`-
|
||||
// prefixed string) so both platforms collapse to `<HOME>`. No-op for the global
|
||||
// fixtures (global install uses the literal `--config-dir`, never realpath-resolved).
|
||||
let realRoot = root;
|
||||
try { realRoot = fs.realpathSync(root); } catch { /* root already gone / not resolvable */ }
|
||||
|
||||
for (const full of allFiles) {
|
||||
// Build POSIX-style relative path for cross-platform stability
|
||||
const rel = path.relative(configDir, full).split(path.sep).join('/');
|
||||
@@ -123,7 +136,10 @@ function buildParityManifest(configDir, root) {
|
||||
// Normalize every occurrence of the temp root so hashes are stable across runs.
|
||||
// Also normalize the package version so the golden survives `npm version` bumps
|
||||
// (the rc release step bakes the new version into hook files before running tests).
|
||||
const normalized = content.toString('utf8').split(root).join('<HOME>').split(PKG_VERSION).join('<VERSION>');
|
||||
const normalized = content.toString('utf8')
|
||||
.split(realRoot).join('<HOME>')
|
||||
.split(root).join('<HOME>')
|
||||
.split(PKG_VERSION).join('<VERSION>');
|
||||
const hash = crypto.createHash('sha256').update(normalized).digest('hex').slice(0, 16);
|
||||
unsorted[rel] = hash;
|
||||
}
|
||||
@@ -194,3 +210,53 @@ for (const runtime of runtimes) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// #2086 (EoS/claude): claude is the reference host and the ONLY runtime with a
|
||||
// distinct LOCAL "legacy flat-commands" layout (commands/gsd-*.md + agents/gsd-*.md).
|
||||
// The loop above asserts the GLOBAL skills layout; this asserts the LOCAL
|
||||
// commands/agents layout is byte-identical too, so folding claude's
|
||||
// `runtime === 'claude'` branches into descriptor-driven hostBehaviors cannot
|
||||
// silently change the local install output (AC1: "both scopes"). NOTE: the
|
||||
// settings.local.json ROUTING itself is excluded here (platform-varying node-runner
|
||||
// path) — that dimension is covered directly by install.test.cjs's #338 suite.
|
||||
test('golden parity — claude (local legacy layout)', 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: 'claude', scope: 'local' });
|
||||
let actual;
|
||||
try {
|
||||
actual = buildParityManifest(configDir, root);
|
||||
} finally {
|
||||
cleanup(root);
|
||||
}
|
||||
|
||||
const fixturePath = path.join(FIXTURE_DIR, 'claude-local.json');
|
||||
|
||||
if (UPDATE) {
|
||||
fs.writeFileSync(fixturePath, JSON.stringify(actual, null, 2) + '\n', 'utf8');
|
||||
process.stdout.write(` [UPDATE] claude-local: wrote ${Object.keys(actual).length} file hashes → ${fixturePath}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(fixturePath)) {
|
||||
assert.fail(
|
||||
`Golden fixture missing for claude-local: ${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 added = Object.keys(actual).filter(k => !(k in golden));
|
||||
const removed = Object.keys(golden).filter(k => !(k in actual));
|
||||
const changed = Object.keys(actual).filter(k => k in golden && actual[k] !== golden[k]);
|
||||
if (added.length || removed.length || changed.length) {
|
||||
const lines = ['Parity mismatch for claude-local:'];
|
||||
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'));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -12,6 +12,10 @@ const os = require('node:os');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const {
|
||||
resolveRuntimeArtifactLayout,
|
||||
} = require('../../gsd-core/bin/lib/runtime-artifact-layout.cjs');
|
||||
|
||||
const INSTALL_SCRIPT = path.join(__dirname, '..', '..', 'bin', 'install.js');
|
||||
const MANIFEST_NAME = 'gsd-file-manifest.json';
|
||||
|
||||
@@ -177,9 +181,27 @@ function manifestAgentCount(manifest) {
|
||||
return Object.keys(manifest.files).filter((k) => k.startsWith('agents/')).length;
|
||||
}
|
||||
|
||||
function collectSkillBasenamesOnDisk(configDir) {
|
||||
/**
|
||||
* Collect gsd-* skill/command basenames actually present on disk under configDir.
|
||||
*
|
||||
* @param {string} configDir
|
||||
* @param {string} [runtime] - when provided, the skills-kind destination is
|
||||
* resolved via resolveRuntimeArtifactLayout so a skills-kind `home` override
|
||||
* (Codex only, ADR-1239 upgrade 3 / #2088: skills -> $HOME/.agents/skills
|
||||
* instead of configDir/skills) is honored. Omitted callers keep the prior
|
||||
* configDir/skills default.
|
||||
* @param {string} [scope='global']
|
||||
*/
|
||||
function collectSkillBasenamesOnDisk(configDir, runtime, scope = 'global') {
|
||||
const out = new Set();
|
||||
const skillsDir = path.join(configDir, 'skills');
|
||||
let skillsDir = path.join(configDir, 'skills');
|
||||
if (runtime) {
|
||||
try {
|
||||
const layout = resolveRuntimeArtifactLayout(runtime, configDir, scope);
|
||||
const skillsKind = layout.kinds.find((k) => k.kind === 'skills');
|
||||
if (skillsKind) skillsDir = path.join(skillsKind.home || configDir, skillsKind.destSubpath);
|
||||
} catch { /* fall back to configDir/skills */ }
|
||||
}
|
||||
if (fs.existsSync(skillsDir)) {
|
||||
for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
|
||||
if (entry.isDirectory() && entry.name.startsWith('gsd-')) {
|
||||
|
||||
@@ -275,7 +275,9 @@ describe('ADR-1239 Phase A: hostIntegration descriptors', () => {
|
||||
hermes: true,
|
||||
kilo: true,
|
||||
kimi: true,
|
||||
opencode: true,
|
||||
// #2087: OpenCode background subagents (v1.15 param, v1.17 default-on) →
|
||||
// dispatch.background/backgroundDispatch true → NOT force-flattened.
|
||||
opencode: false,
|
||||
qwen: true,
|
||||
trae: true,
|
||||
windsurf: true,
|
||||
|
||||
@@ -63,6 +63,31 @@ const {
|
||||
collectSkillBasenamesOnDisk,
|
||||
} = require('./helpers/install-shared.cjs');
|
||||
|
||||
/**
|
||||
* collectSkillBasenamesOnDisk(configDir, runtime, scope) re-resolves the
|
||||
* runtime's skills-kind layout via os.homedir(). runMinimalInstall() already
|
||||
* sandboxes HOME/USERPROFILE to `root` for the spawned install subprocess,
|
||||
* but that sandboxing does not persist into this (parent) process — without
|
||||
* re-sandboxing here, Codex's skills-kind `home: ".agents"` override
|
||||
* (ADR-1239 upgrade 3, #2088) would resolve against the developer's REAL
|
||||
* $HOME/.agents/skills instead of the sandboxed install root. Sandbox
|
||||
* HOME/USERPROFILE to `root` for the synchronous duration of the on-disk scan.
|
||||
*/
|
||||
function collectSkillBasenamesOnDiskSandboxed(configDir, runtime, scope, root) {
|
||||
const savedHome = process.env.HOME;
|
||||
const savedUserProfile = process.env.USERPROFILE;
|
||||
process.env.HOME = root;
|
||||
process.env.USERPROFILE = root;
|
||||
try {
|
||||
return collectSkillBasenamesOnDisk(configDir, runtime, scope);
|
||||
} finally {
|
||||
if (savedHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = savedHome;
|
||||
if (savedUserProfile === undefined) delete process.env.USERPROFILE;
|
||||
else process.env.USERPROFILE = savedUserProfile;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Section 9: install-profiles — MINIMAL_SKILL_ALLOWLIST ───────────────────
|
||||
|
||||
describe('install-profiles: MINIMAL_SKILL_ALLOWLIST', () => {
|
||||
@@ -391,7 +416,7 @@ describe('install: on-disk skill files match manifest for --minimal', () => {
|
||||
});
|
||||
try {
|
||||
assert.ok(manifest);
|
||||
const onDisk = collectSkillBasenamesOnDisk(configDir);
|
||||
const onDisk = collectSkillBasenamesOnDiskSandboxed(configDir, runtime, scope, root);
|
||||
const inManifest = manifestSkillSet(manifest);
|
||||
assert.deepStrictEqual([...onDisk].sort(), [...inManifest].sort());
|
||||
// Not the shared listAgentFiles() helper: asserts on the INSTALLED
|
||||
@@ -542,10 +567,15 @@ describe('install: Codex full → minimal downgrade cleans stale agent state', (
|
||||
].join('\n');
|
||||
fs.writeFileSync(path.join(targetDir, 'config.toml'), codexConfig);
|
||||
|
||||
// Sandbox HOME/USERPROFILE to targetDir: Codex's skills-kind `home: ".agents"`
|
||||
// override (ADR-1239 upgrade 3, #2088) resolves via os.homedir(), so an
|
||||
// unsandboxed spawn here would write gsd-* skill dirs into the developer's
|
||||
// real $HOME/.agents/skills. This test only asserts on agents/ and
|
||||
// config.toml (both under targetDir), so the sandbox has no effect on intent.
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[INSTALL_SCRIPT, '--codex', '--global', '--config-dir', targetDir, '--minimal'],
|
||||
{ encoding: 'utf8', env: installerEnv() },
|
||||
{ encoding: 'utf8', env: installerEnv({ HOME: targetDir, USERPROFILE: targetDir }) },
|
||||
);
|
||||
assert.ok(result.stdout || result.stderr);
|
||||
|
||||
|
||||
@@ -18,6 +18,10 @@ const {
|
||||
installRuntimeArtifacts,
|
||||
} = require('../gsd-core/bin/lib/install-engine.cjs');
|
||||
|
||||
const {
|
||||
resolveRuntimeArtifactLayout,
|
||||
} = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs');
|
||||
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const {
|
||||
@@ -58,16 +62,55 @@ const FLAT = [
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Codex resolves its skills-kind destination via os.homedir() + a 'skills'-kind
|
||||
* 'home: ".agents"' layout override (ADR-1239 EoS upgrade 3, #2088), NOT
|
||||
* tmpDir/skills. Sandbox HOME/USERPROFILE to tmpDir for the duration of the
|
||||
* synchronous callback so codex's resolved skills dir becomes
|
||||
* tmpDir/.agents/skills instead of the developer's real ~/.agents/skills.
|
||||
*/
|
||||
function withSandboxedHome(tmpDir, fn) {
|
||||
const savedHome = process.env.HOME;
|
||||
const savedUserProfile = process.env.USERPROFILE;
|
||||
process.env.HOME = tmpDir;
|
||||
process.env.USERPROFILE = tmpDir;
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
if (savedHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = savedHome;
|
||||
if (savedUserProfile === undefined) delete process.env.USERPROFILE;
|
||||
else process.env.USERPROFILE = savedUserProfile;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a fresh temp dir, run installRuntimeArtifacts into it, and return
|
||||
* the tmpDir path. Caller must cleanup in finally.
|
||||
* the tmpDir path. Caller must cleanup in finally. HOME is sandboxed to
|
||||
* tmpDir for the install call so codex never writes to the real ~/.agents/skills.
|
||||
*/
|
||||
function runInstall(runtime, scope, resolved) {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), `gsd-nest-test-${runtime}-`));
|
||||
installRuntimeArtifacts(runtime, tmpDir, scope, resolved);
|
||||
withSandboxedHome(tmpDir, () => installRuntimeArtifacts(runtime, tmpDir, scope, resolved));
|
||||
return tmpDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the skills-kind destination directory for a runtime, honoring the
|
||||
* skills-kind 'home' override (codex only — resolves under tmpDir/.agents
|
||||
* once HOME is sandboxed). All other runtimes have no 'home' override, so
|
||||
* this falls back to tmpDir/<destSubpath>, matching the static skillsSub
|
||||
* tables above.
|
||||
*/
|
||||
function resolveSkillsDir(runtime, tmpDir, scope) {
|
||||
return withSandboxedHome(tmpDir, () => {
|
||||
const layout = resolveRuntimeArtifactLayout(runtime, tmpDir, scope);
|
||||
const skillsKind = layout.kinds.find((k) => k.kind === 'skills');
|
||||
assert.ok(skillsKind, `${runtime} must have skills kind`);
|
||||
return path.join(skillsKind.home || tmpDir, skillsKind.destSubpath);
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve the full profile once (shared by all installs)
|
||||
const MANIFEST = loadSkillsManifest(COMMANDS_GSD);
|
||||
const RESOLVED_FULL = resolveProfile({ modes: ['full'], manifest: MANIFEST });
|
||||
@@ -267,7 +310,7 @@ describe('claude: total top-level gsd- entries >= 60 (flat layout, #924)', () =>
|
||||
// FLAT runtimes: concrete skills stay top-level, no nesting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
for (const { runtime, scope, skillsSub } of FLAT) {
|
||||
for (const { runtime, scope } of FLAT) {
|
||||
describe(`${runtime} (flat layout)`, () => {
|
||||
let tmpDir;
|
||||
|
||||
@@ -282,7 +325,7 @@ for (const { runtime, scope, skillsSub } of FLAT) {
|
||||
});
|
||||
|
||||
test(`${runtime}: stays flat — concrete skills remain top-level, no nesting`, () => {
|
||||
const skillsDir = path.join(tmpDir, skillsSub);
|
||||
const skillsDir = resolveSkillsDir(runtime, tmpDir, scope);
|
||||
assert.ok(fs.existsSync(skillsDir), `skillsDir must exist: ${skillsDir}`);
|
||||
|
||||
const topLevel = fs.readdirSync(skillsDir);
|
||||
|
||||
@@ -187,11 +187,31 @@ function readAllSkillMd(dir) {
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
// Codex resolves its skills-kind destination via os.homedir() + a 'skills'-kind
|
||||
// 'home: ".agents"' layout override (ADR-1239 EoS upgrade 3, #2088), NOT
|
||||
// configDir/skills. Without sandboxing HOME, an in-process codex install would
|
||||
// write to (and an uninstall would mutate) the developer's REAL ~/.agents/skills.
|
||||
// Sandbox HOME/USERPROFILE to configDir before resolving the layout or invoking
|
||||
// install/uninstall so codex's resolved skills dir is configDir/.agents/skills.
|
||||
function sandboxHome(t, dir) {
|
||||
const savedHome = process.env.HOME;
|
||||
const savedUserProfile = process.env.USERPROFILE;
|
||||
process.env.HOME = dir;
|
||||
process.env.USERPROFILE = dir;
|
||||
t.after(() => {
|
||||
if (savedHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = savedHome;
|
||||
if (savedUserProfile === undefined) delete process.env.USERPROFILE;
|
||||
else process.env.USERPROFILE = savedUserProfile;
|
||||
});
|
||||
}
|
||||
|
||||
describe('installRuntimeArtifacts — skills runtimes write gsd-prefixed skill dirs', () => {
|
||||
for (const runtime of SKILLS_RUNTIMES_LAYOUT) {
|
||||
test(`${runtime}: gsd-prefixed skill dirs in skills/`, (t) => {
|
||||
const configDir = createTempDir(`gsd-ial-${runtime}-`);
|
||||
t.after(() => cleanup(configDir));
|
||||
sandboxHome(t, configDir);
|
||||
|
||||
assert.strictEqual(typeof installRuntimeArtifacts, 'function');
|
||||
installRuntimeArtifacts(runtime, configDir, 'global', RESOLVED_CORE);
|
||||
@@ -200,7 +220,7 @@ describe('installRuntimeArtifacts — skills runtimes write gsd-prefixed skill d
|
||||
const skillsKind = layout.kinds.find(k => k.kind === 'skills');
|
||||
assert.ok(skillsKind, `${runtime} must have skills kind`);
|
||||
|
||||
const destDir = path.join(configDir, skillsKind.destSubpath);
|
||||
const destDir = path.join(skillsKind.home || configDir, skillsKind.destSubpath);
|
||||
assert.ok(fs.existsSync(destDir));
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(destDir, `${skillsKind.prefix}help`, 'SKILL.md')),
|
||||
@@ -481,6 +501,7 @@ describe('uninstallRuntimeArtifacts — removes gsd-owned entries, preserves for
|
||||
test(`${runtime}: gsd entries removed, foreign preserved`, (t) => {
|
||||
const configDir = createTempDir(`gsd-ual-${runtime}-`);
|
||||
t.after(() => cleanup(configDir));
|
||||
sandboxHome(t, configDir);
|
||||
|
||||
const { uninstallRuntimeArtifacts } = require('../bin/install.js');
|
||||
assert.strictEqual(typeof uninstallRuntimeArtifacts, 'function');
|
||||
@@ -519,7 +540,7 @@ describe('uninstallRuntimeArtifacts — removes gsd-owned entries, preserves for
|
||||
}
|
||||
|
||||
for (const kind of layout.kinds) {
|
||||
const destDir = path.join(configDir, kind.destSubpath);
|
||||
const destDir = path.join(kind.home || configDir, kind.destSubpath);
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
if (kind.kind === 'skills') {
|
||||
writeSkillEntry(destDir, kind.prefix, 'help');
|
||||
@@ -545,7 +566,7 @@ describe('uninstallRuntimeArtifacts — removes gsd-owned entries, preserves for
|
||||
uninstallRuntimeArtifacts(runtime, configDir, 'global');
|
||||
|
||||
for (const kind of layout.kinds) {
|
||||
const destDir = path.join(configDir, kind.destSubpath);
|
||||
const destDir = path.join(kind.home || configDir, kind.destSubpath);
|
||||
if (kind.kind === 'skills') {
|
||||
assert.ok(!fs.existsSync(path.join(destDir, `${kind.prefix}help`)));
|
||||
assert.ok(!fs.existsSync(path.join(destDir, `${kind.prefix}phase`)));
|
||||
|
||||
@@ -9957,7 +9957,9 @@ function readWorkflow() {
|
||||
describe('install.js --skills-root', () => {
|
||||
const CASES = [
|
||||
{ runtime: 'claude', expected: path.join(os.homedir(), '.claude', 'skills') },
|
||||
{ runtime: 'codex', expected: path.join(os.homedir(), '.codex', 'skills') },
|
||||
// #2088 (ADR-1239 upgrade 3): Codex skills resolve to the canonical
|
||||
// $HOME/.agents/skills root (skills-kind home override), not $CODEX_HOME/skills.
|
||||
{ runtime: 'codex', expected: path.join(os.homedir(), '.agents', 'skills') },
|
||||
{ runtime: 'copilot', expected: path.join(os.homedir(), '.copilot', 'skills') },
|
||||
{ runtime: 'cursor', expected: path.join(os.homedir(), '.cursor', 'skills') },
|
||||
{ runtime: 'trae', expected: path.join(os.homedir(), '.trae', 'skills') },
|
||||
@@ -10182,10 +10184,15 @@ const INSTALL = path.join(__dirname, '..', 'bin', 'install.js');
|
||||
*/
|
||||
function installAndRead(runtime) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `gsd-inst-${runtime}-`));
|
||||
// Sandbox HOME/USERPROFILE to `dir`: Codex's skills-kind `home: ".agents"`
|
||||
// override (ADR-1239 upgrade 3, #2088) resolves via os.homedir(), so an
|
||||
// unsandboxed real install here would write a full (non-minimal) gsd-* skill
|
||||
// set into the developer/CI machine's real $HOME/.agents/skills. Harmless
|
||||
// no-op for cursor/claude, which have no skills-kind home override.
|
||||
const res = spawnSync(
|
||||
process.execPath,
|
||||
[INSTALL, `--${runtime}`, '--global', '--config-dir', dir],
|
||||
{ encoding: 'utf8', timeout: 120000 },
|
||||
{ encoding: 'utf8', timeout: 120000, env: { ...process.env, HOME: dir, USERPROFILE: dir } },
|
||||
);
|
||||
assert.strictEqual(res.status, 0, `install --${runtime} failed: ${res.stderr || res.stdout}`);
|
||||
const wf = path.join(dir, 'gsd-core', 'workflows', 'execute-phase.md');
|
||||
|
||||
@@ -81,6 +81,19 @@ function withEnv(key, value, fn) {
|
||||
}
|
||||
}
|
||||
|
||||
// #2088: Codex CLI skills install to `$HOME/.agents/skills` (resolved via
|
||||
// os.homedir()), not `$CODEX_HOME/skills`. In-process codex installs must
|
||||
// sandbox HOME (and USERPROFILE, for Windows os.homedir() resolution) to the
|
||||
// test's codexHome dir, or skills get materialized into the real developer
|
||||
// home directory. withEnv saves/restores a single key, so nesting is safe.
|
||||
function withCodexEnv(codexHome, fn) {
|
||||
return withEnv('CODEX_HOME', codexHome, () =>
|
||||
withEnv('HOME', codexHome, () =>
|
||||
withEnv('USERPROFILE', codexHome, fn)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function captureConsole(fn) {
|
||||
const originalLog = console.log;
|
||||
const originalWarn = console.warn;
|
||||
@@ -216,11 +229,20 @@ function assertFreshInstallContract(runtime, targetDir) {
|
||||
}
|
||||
|
||||
if (contract.surface === 'flat-skills') {
|
||||
// Pre-#3562: codex was special-cased to expect zero gsd-* skill dirs
|
||||
// (assumption: Codex auto-discovers from workflows). That assumption
|
||||
// does not hold for Codex CLI 0.130.0 — fresh installs now materialize
|
||||
// the same flat-skills surface as the other runtimes.
|
||||
assertHasGsdDirectory(targetDir, 'skills');
|
||||
if (runtime === 'codex') {
|
||||
// #2088: Codex CLI skills install to the canonical `$HOME/.agents/skills`
|
||||
// root (resolved via os.homedir()), NOT `<config-dir>/skills`. Here
|
||||
// runInstallerCli sandboxes HOME to <dirname(targetDir)>/home, so assert
|
||||
// the skill dir under that sandboxed home instead of under targetDir.
|
||||
const codexSandboxHome = path.join(path.dirname(targetDir), 'home');
|
||||
assertHasGsdDirectory(path.join(codexSandboxHome, '.agents'), 'skills');
|
||||
} else {
|
||||
// Pre-#3562: codex was special-cased to expect zero gsd-* skill dirs
|
||||
// (assumption: Codex auto-discovers from workflows). That assumption
|
||||
// does not hold for Codex CLI 0.130.0 — fresh installs now materialize
|
||||
// the same flat-skills surface as the other runtimes.
|
||||
assertHasGsdDirectory(targetDir, 'skills');
|
||||
}
|
||||
} else if (contract.surface === 'hermes-skills') {
|
||||
// Hermes layout uses prefix: '' — skill dirs have bare stem names (no gsd- prefix).
|
||||
// Assert that the category dir contains at least one skill dir with SKILL.md.
|
||||
@@ -335,7 +357,7 @@ describe('installer migration install integration', { concurrency: false }, () =
|
||||
});
|
||||
|
||||
const { output } = captureConsole(() =>
|
||||
withEnv('CODEX_HOME', codexHome, () => install(true, 'codex'))
|
||||
withCodexEnv(codexHome, () => install(true, 'codex'))
|
||||
);
|
||||
|
||||
const plainOutput = stripAnsi(output);
|
||||
@@ -353,13 +375,17 @@ describe('installer migration install integration', { concurrency: false }, () =
|
||||
|
||||
assert.throws(
|
||||
() => captureConsole(() =>
|
||||
withEnv('CODEX_HOME', codexHome, () => install(true, 'codex'))
|
||||
withCodexEnv(codexHome, () => install(true, 'codex'))
|
||||
),
|
||||
/installer migration blocked/
|
||||
);
|
||||
|
||||
assert.equal(fs.readFileSync(path.join(codexHome, 'hooks/gsd-retired-hook.txt'), 'utf8'), 'old gsd hook\n');
|
||||
assert.equal(fs.existsSync(path.join(codexHome, 'skills')), false);
|
||||
// #2088: with HOME sandboxed to codexHome via withCodexEnv, Codex's
|
||||
// canonical skill root ($HOME/.agents/skills) resolves under codexHome
|
||||
// too — assert nothing was materialized there when the install is blocked.
|
||||
assert.equal(fs.existsSync(path.join(codexHome, '.agents', 'skills')), false);
|
||||
assert.equal(fs.existsSync(path.join(codexHome, 'gsd-core', 'VERSION')), false);
|
||||
});
|
||||
|
||||
@@ -431,7 +457,7 @@ describe('installer migration install integration', { concurrency: false }, () =
|
||||
assert.throws(
|
||||
() => captureConsole(() =>
|
||||
withEnv('CLAUDE_CONFIG_DIR', claudeHome, () =>
|
||||
withEnv('CODEX_HOME', codexHome, () =>
|
||||
withCodexEnv(codexHome, () =>
|
||||
withWriteFailure(path.join(codexHome, 'gsd-core', 'VERSION'), () =>
|
||||
installModule.installAllRuntimes(['claude', 'codex'], true, false)
|
||||
)
|
||||
|
||||
@@ -1618,12 +1618,24 @@ const BUILD_HOOKS_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js
|
||||
|
||||
function withCodexHome(codexHome, fn) {
|
||||
const previousCodexHome = process.env.CODEX_HOME;
|
||||
// #2088 (ADR-1239 upgrade 3): Codex skills now install to $HOME/.agents/skills
|
||||
// (os.homedir()-relative, independent of CODEX_HOME). Sandbox HOME (and
|
||||
// USERPROFILE) to codexHome so in-process installs never write to the
|
||||
// developer/CI machine's real home directory.
|
||||
const previousHome = process.env.HOME;
|
||||
const previousUserProfile = process.env.USERPROFILE;
|
||||
process.env.CODEX_HOME = codexHome;
|
||||
process.env.HOME = codexHome;
|
||||
process.env.USERPROFILE = codexHome;
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
if (previousCodexHome == null) delete process.env.CODEX_HOME;
|
||||
else process.env.CODEX_HOME = previousCodexHome;
|
||||
if (previousHome == null) delete process.env.HOME;
|
||||
else process.env.HOME = previousHome;
|
||||
if (previousUserProfile == null) delete process.env.USERPROFILE;
|
||||
else process.env.USERPROFILE = previousUserProfile;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
122
tests/opencode-imperative-reference.test.cjs
Normal file
122
tests/opencode-imperative-reference.test.cjs
Normal file
@@ -0,0 +1,122 @@
|
||||
// allow-test-rule: AC2 requires asserting no `runtime === 'opencode'` string-equality branch remains in bin/install.js/src — the descriptor-migration contract is a property of the source text, so a source-grep is the only faithful check (#2087)
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* opencode imperative reference host — ADR-1239 Phase D / #2087 (EoS/opencode).
|
||||
*
|
||||
* Proves opencode is driven through the PUBLIC Host-Integration Interface (the
|
||||
* imperative adapter), that its negotiated axes classify + negotiate correctly,
|
||||
* that negotiation fails CLOSED on a corrupted descriptor, that the Context7-
|
||||
* verified dispatch UPGRADE (background subagents, v1.15/v1.17) changes
|
||||
* `shouldFlattenDispatch`, and that the migration retired the hardcoded
|
||||
* `runtime === 'opencode'` / `isOpencode` branches (folded into descriptor-driven
|
||||
* `runtime.hostBehaviors` + the combined-family engine install path).
|
||||
*/
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const { createImperativeAdapter } = require('../gsd-core/bin/lib/adapter-imperative.cjs');
|
||||
const {
|
||||
profileOf,
|
||||
negotiateHostCapabilities,
|
||||
shouldFlattenDispatch,
|
||||
extensionEventSurfaceFor,
|
||||
PROFILE_BASELINES,
|
||||
UNDOCUMENTED,
|
||||
} = require('../gsd-core/bin/lib/host-integration.cjs');
|
||||
|
||||
const OC_CAP = JSON.parse(
|
||||
fs.readFileSync(path.join(__dirname, '..', 'capabilities', 'opencode', 'capability.json'), 'utf8'),
|
||||
);
|
||||
const OC_AXES = OC_CAP.runtime.hostIntegration;
|
||||
|
||||
// -- AC2: driven through the public interface (imperative adapter) -----------
|
||||
|
||||
test('createImperativeAdapter classifies opencode as imperative + composes the registry', () => {
|
||||
const adapter = createImperativeAdapter({ runtime: 'opencode' });
|
||||
assert.equal(adapter.kind, 'imperative');
|
||||
assert.equal(adapter.runtime, 'opencode');
|
||||
assert.ok(adapter.registry && typeof adapter.registry === 'object');
|
||||
assert.equal(typeof adapter.install, 'function');
|
||||
assert.equal(typeof adapter.uninstall, 'function');
|
||||
});
|
||||
|
||||
test('opencode axes classify as the programmatic-cli reference profile', () => {
|
||||
assert.equal(profileOf(OC_AXES), 'programmatic-cli');
|
||||
});
|
||||
|
||||
// -- AC4: the Context7-verified UPGRADE (background dispatch) -----------------
|
||||
|
||||
test('opencode descriptor declares background dispatch true/true (v1.15/v1.17 upgrade)', () => {
|
||||
assert.equal(OC_AXES.dispatch.background, true, 'background subagents (v1.15 param, v1.17 default-on)');
|
||||
assert.equal(OC_AXES.dispatch.backgroundDispatch, true);
|
||||
});
|
||||
|
||||
test('background UPGRADE changes shouldFlattenDispatch: false now (may background), true for the old axes', () => {
|
||||
// Post-upgrade: opencode may run subagents concurrently → NOT force-flattened.
|
||||
assert.equal(shouldFlattenDispatch(OC_AXES.dispatch), false,
|
||||
'with background:true+backgroundDispatch:true, GSD must NOT force-flatten opencode dispatch');
|
||||
// Pin the behavioral change: the pre-#2087 axes DID force-flatten.
|
||||
const preUpgrade = { ...OC_AXES.dispatch, background: false, backgroundDispatch: 'undocumented' };
|
||||
assert.equal(shouldFlattenDispatch(preUpgrade), true,
|
||||
'pre-upgrade (background:false) opencode was force-flattened — this is the behavioral change #2087 lands');
|
||||
});
|
||||
|
||||
test('opencode extension-event surface includes the #2087 additions (permission + session.error)', () => {
|
||||
const surface = extensionEventSurfaceFor('opencode');
|
||||
assert.ok(surface, 'opencode is a consumed extensionEvents dialect');
|
||||
for (const ev of ['permission.asked', 'permission.replied', 'session.error']) {
|
||||
assert.ok(surface.includes(ev), `#2087 adds ${ev} to the opencode extension-event surface`);
|
||||
}
|
||||
// The engine still owns phase sequencing — no workflow-phase events on the bus.
|
||||
assert.ok(!surface.some((e) => /plan:|verify:|ship:/.test(e)));
|
||||
});
|
||||
|
||||
// -- AC5: negotiation fails CLOSED on a corrupted descriptor ------------------
|
||||
|
||||
test('negotiateHostCapabilities never throws for opencode, even fully corrupted', () => {
|
||||
assert.doesNotThrow(() => negotiateHostCapabilities({}));
|
||||
assert.doesNotThrow(() => negotiateHostCapabilities({ ...OC_AXES, embeddingMode: UNDOCUMENTED }));
|
||||
assert.doesNotThrow(() => negotiateHostCapabilities({ ...OC_AXES, embeddingMode: 'future-unknown' }));
|
||||
});
|
||||
|
||||
test('a partial/empty opencode descriptor degrades to the safe floor, not the programmatic-cli baseline', () => {
|
||||
const result = negotiateHostCapabilities({});
|
||||
assert.equal(result.effective.embeddingMode, 'declarative', 'omitted embeddingMode degrades closed');
|
||||
assert.equal(result.effective.hookBus, 'none');
|
||||
assert.notDeepEqual(result.effective, PROFILE_BASELINES['programmatic-cli']);
|
||||
assert.ok(result.warnings.length > 0);
|
||||
});
|
||||
|
||||
// -- AC2: the hardcoded branches are retired ---------------------------------
|
||||
|
||||
test('opencode descriptor declares runtime.hostBehaviors (the folded-in behaviors)', () => {
|
||||
const hb = OC_CAP.runtime.hostBehaviors;
|
||||
assert.ok(hb && typeof hb === 'object');
|
||||
assert.equal(hb.combinedFamilyInstall, true, 'commands+skills+plugin install runs through the engine (adapter)');
|
||||
assert.equal(hb.reapplyCommand, '/gsd-update --reapply');
|
||||
assert.equal(hb.attributionConfigResolver, 'opencode');
|
||||
assert.equal(hb.flatCommandDir, 'command');
|
||||
assert.equal(hb.frontmatterDialect, 'opencode');
|
||||
assert.equal(hb.skipHomePrefixSubstitution, true);
|
||||
assert.equal(hb.skipSettingsUi, true);
|
||||
assert.equal(hb.skipUpdateBannerCommand, true);
|
||||
assert.equal(hb.skipCodexSkillsManifest, true);
|
||||
assert.equal(hb.nativePlugin.file, 'gsd-core.js');
|
||||
assert.equal(hb.nativePlugin.source, '.opencode/plugins/gsd-core.js');
|
||||
});
|
||||
|
||||
test('no `runtime === "opencode"` string-equality branch remains in the install source (AC2)', () => {
|
||||
const strip = (src) => src
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/\/\/[^\r\n]*/g, '')
|
||||
.replace(/`[^`]*`/g, '');
|
||||
for (const rel of ['bin/install.js', 'src/install-engine.cts', 'src/runtime-artifact-conversion.cts']) {
|
||||
const src = fs.readFileSync(path.join(__dirname, '..', rel), 'utf8');
|
||||
const offenders = strip(src).match(/runtime\s*[!=]==\s*'opencode'/g) || [];
|
||||
assert.deepEqual(offenders, [], `AC2: no hardcoded runtime==='opencode' branch may remain in ${rel}; found: ${offenders.join(', ')}`);
|
||||
}
|
||||
});
|
||||
@@ -340,10 +340,15 @@ test('plugin implements the full declared opencode extension-event surface (Clau
|
||||
}
|
||||
// Session/file events dispatch through the `event` handler.
|
||||
assert.equal(typeof handlers.event, 'function', 'plugin exposes an event dispatcher');
|
||||
// Every declared surface event resolves to a plugin handler.
|
||||
// Every declared surface event resolves to a plugin handler. Session /
|
||||
// permission / error events dispatch through the `event` handler (not
|
||||
// top-level handler keys). #2087 added permission.asked/replied + session.error.
|
||||
const EVENT_DISPATCHED = new Set([
|
||||
'session.created', 'session.idle', 'file.edited',
|
||||
'permission.asked', 'permission.replied', 'session.error',
|
||||
]);
|
||||
for (const ev of surface) {
|
||||
const covered = typeof handlers[ev] === 'function'
|
||||
|| ev === 'session.created' || ev === 'session.idle' || ev === 'file.edited';
|
||||
const covered = typeof handlers[ev] === 'function' || EVENT_DISPATCHED.has(ev);
|
||||
assert.ok(covered, `plugin covers opencode extension event: ${ev}`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const phaseId = require('../gsd-core/bin/lib/phase-id.cjs');
|
||||
const fc = require('fast-check');
|
||||
|
||||
// ─── escapeRegex ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -450,3 +451,169 @@ describe('getPhaseDirFromPhaseId', () => {
|
||||
assert.ok(!result.endsWith('-'));
|
||||
});
|
||||
});
|
||||
|
||||
// ─── parsePhaseFromProse (#2121, anchored — fixes #2111) ─────────────────────
|
||||
|
||||
describe('parsePhaseFromProse', () => {
|
||||
test('null / empty input yields null phase and name', () => {
|
||||
assert.deepEqual(phaseId.parsePhaseFromProse(null), { phase: null, name: null });
|
||||
assert.deepEqual(phaseId.parsePhaseFromProse(''), { phase: null, name: null });
|
||||
});
|
||||
|
||||
test('#2111: a milestone-completion string carries no phase', () => {
|
||||
assert.equal(phaseId.parsePhaseFromProse('Milestone v0.5 complete').phase, null);
|
||||
assert.equal(phaseId.parsePhaseFromProse('Milestone v1.0 complete').phase, null);
|
||||
assert.equal(phaseId.parsePhaseFromProse('Milestone v2.10 complete').phase, null);
|
||||
});
|
||||
|
||||
test('#2111: a bare version token or stray numeral is not a phase', () => {
|
||||
assert.equal(phaseId.parsePhaseFromProse('v0.5').phase, null);
|
||||
assert.equal(phaseId.parsePhaseFromProse('v1.0').phase, null);
|
||||
assert.equal(phaseId.parsePhaseFromProse('Fixed 12 bugs in v2.3').phase, null);
|
||||
});
|
||||
|
||||
test('a genuine phase value (starting with the token) is parsed', () => {
|
||||
assert.deepEqual(phaseId.parsePhaseFromProse('3 of 4 (Delta)'), { phase: '3', name: 'Delta' });
|
||||
assert.deepEqual(phaseId.parsePhaseFromProse('3A — Delta'), { phase: '3A', name: 'Delta' });
|
||||
assert.equal(phaseId.parsePhaseFromProse('12.1: Setup').phase, '12.1');
|
||||
assert.equal(phaseId.parsePhaseFromProse('29 of 30').phase, '29');
|
||||
assert.equal(phaseId.parsePhaseFromProse('029').phase, '029');
|
||||
});
|
||||
|
||||
test('a leading project-code prefix is tolerated but not captured (bare token)', () => {
|
||||
assert.equal(phaseId.parsePhaseFromProse('MEM-01 — Foo').phase, '01');
|
||||
assert.equal(phaseId.parsePhaseFromProse('AB-29 of 30').phase, '29');
|
||||
});
|
||||
|
||||
test('an optional leading "Phase" label is tolerated', () => {
|
||||
assert.equal(phaseId.parsePhaseFromProse('Phase 3A — Delta').phase, '3A');
|
||||
});
|
||||
|
||||
test('a status-word parenthetical is filtered from the name (preserved behavior)', () => {
|
||||
// parenName wins over the em-dash tail; "executing" is a status word → name null.
|
||||
assert.deepEqual(phaseId.parsePhaseFromProse('3A — Delta (executing)'), { phase: '3A', name: null });
|
||||
assert.equal(phaseId.parsePhaseFromProse('3 (complete)').name, null);
|
||||
});
|
||||
|
||||
test('#2124 review: name quantifiers are length-bounded (ReDoS guard)', () => {
|
||||
// A parenthetical within the bound extracts; one longer than the bound is
|
||||
// NOT matched — the cap is what prevents O(n^2) backtracking on a crafted
|
||||
// untrusted value. Removing the bound would extract the long name → fail.
|
||||
assert.equal(phaseId.parsePhaseFromProse('3 (Delta)').name, 'Delta');
|
||||
assert.equal(phaseId.parsePhaseFromProse(`3 (${'x'.repeat(201)})`).name, null);
|
||||
// A long unterminated "(" run yields no name and still parses the phase.
|
||||
assert.deepEqual(phaseId.parsePhaseFromProse(`3 ${'('.repeat(5000)}`), { phase: '3', name: null });
|
||||
});
|
||||
|
||||
test('#2124 review: non-string input is coerced, never throws', () => {
|
||||
assert.doesNotThrow(() => phaseId.parsePhaseFromProse(3));
|
||||
assert.equal(phaseId.parsePhaseFromProse(3).phase, '3');
|
||||
assert.deepEqual(phaseId.parsePhaseFromProse(true), { phase: null, name: null });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── stripConfiguredProjectCodePrefix (#2121 / #2104, config-aware) ───────────
|
||||
|
||||
describe('stripConfiguredProjectCodePrefix', () => {
|
||||
test('#2104: a foreign prefix is preserved (not collapsed to a bare phase)', () => {
|
||||
assert.equal(phaseId.stripConfiguredProjectCodePrefix('MEM-01', 'LKML'), 'MEM-01');
|
||||
});
|
||||
|
||||
test('the configured prefix is stripped (case-insensitive)', () => {
|
||||
assert.equal(phaseId.stripConfiguredProjectCodePrefix('CK-01', 'CK'), '01');
|
||||
assert.equal(phaseId.stripConfiguredProjectCodePrefix('LKML-29', 'lkml'), '29');
|
||||
assert.equal(phaseId.stripConfiguredProjectCodePrefix('AB-29', 'AB'), '29');
|
||||
});
|
||||
|
||||
test('a value with no prefix is returned unchanged', () => {
|
||||
assert.equal(phaseId.stripConfiguredProjectCodePrefix('01', 'CK'), '01');
|
||||
assert.equal(phaseId.stripConfiguredProjectCodePrefix('029', 'CK'), '029');
|
||||
});
|
||||
|
||||
test('an absent/empty projectCode preserves the value verbatim', () => {
|
||||
assert.equal(phaseId.stripConfiguredProjectCodePrefix('MEM-01', ''), 'MEM-01');
|
||||
assert.equal(phaseId.stripConfiguredProjectCodePrefix('MEM-01', null), 'MEM-01');
|
||||
assert.equal(phaseId.stripConfiguredProjectCodePrefix('MEM-01', undefined), 'MEM-01');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── isForeignPrefixedPhaseQuery (#2121 / #2056) ─────────────────────────────
|
||||
|
||||
describe('isForeignPrefixedPhaseQuery', () => {
|
||||
test('a prefix that is not the configured code is foreign', () => {
|
||||
assert.equal(phaseId.isForeignPrefixedPhaseQuery('MEM-01', 'LKML'), true);
|
||||
});
|
||||
|
||||
test('the configured prefix is not foreign (case-insensitive)', () => {
|
||||
assert.equal(phaseId.isForeignPrefixedPhaseQuery('CK-01', 'CK'), false);
|
||||
assert.equal(phaseId.isForeignPrefixedPhaseQuery('ck-01', 'CK'), false);
|
||||
});
|
||||
|
||||
test('a value with no prefix is never foreign', () => {
|
||||
assert.equal(phaseId.isForeignPrefixedPhaseQuery('01', 'CK'), false);
|
||||
assert.equal(phaseId.isForeignPrefixedPhaseQuery('29', 'AB'), false);
|
||||
});
|
||||
|
||||
test('a prefixed query with no configured code is foreign; a bare one is not', () => {
|
||||
assert.equal(phaseId.isForeignPrefixedPhaseQuery('MEM-01', ''), true);
|
||||
assert.equal(phaseId.isForeignPrefixedPhaseQuery('MEM-01', null), true);
|
||||
assert.equal(phaseId.isForeignPrefixedPhaseQuery('01', ''), false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── roadmapPhaseLookupSources (#2121, owned here after the move) ─────────────
|
||||
|
||||
describe('roadmapPhaseLookupSources', () => {
|
||||
const PREFIX_TOLERANT = `${phaseId.OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}0*29`;
|
||||
|
||||
test('a bare numeric query yields the numeric then prefix-tolerant sources', () => {
|
||||
const sources = phaseId.roadmapPhaseLookupSources('29');
|
||||
assert.deepEqual(sources, ['0*29', PREFIX_TOLERANT]);
|
||||
});
|
||||
|
||||
test('the bare numeric source precedes the prefix-tolerant fallback', () => {
|
||||
const sources = phaseId.roadmapPhaseLookupSources('29');
|
||||
assert.ok(sources.indexOf('0*29') < sources.indexOf(PREFIX_TOLERANT));
|
||||
});
|
||||
|
||||
test('a project-code-prefixed query adds the exact source first (3 sources)', () => {
|
||||
const sources = phaseId.roadmapPhaseLookupSources('AB-29');
|
||||
assert.equal(sources.length, 3);
|
||||
assert.equal(sources[0], 'AB-29');
|
||||
assert.ok(sources.includes('0*29'));
|
||||
assert.ok(sources.includes(PREFIX_TOLERANT));
|
||||
});
|
||||
|
||||
test('zero-padding is tolerated: 029 resolves the same sources as 29', () => {
|
||||
assert.deepEqual(phaseId.roadmapPhaseLookupSources('029'), phaseId.roadmapPhaseLookupSources('29'));
|
||||
});
|
||||
|
||||
test('sources are deduplicated', () => {
|
||||
const sources = phaseId.roadmapPhaseLookupSources('29');
|
||||
assert.equal(sources.length, new Set(sources).size);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #2121 property tests (fast-check) ───────────────────────────────────────
|
||||
|
||||
describe('phase-id canonical surface — properties', () => {
|
||||
test('#2111 invariant: a "Milestone vX.Y complete" string never yields a phase', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.nat(999), fc.nat(999), (major, minor) => {
|
||||
return phaseId.parsePhaseFromProse(`Milestone v${major}.${minor} complete`).phase === null;
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('parse↔normalize: a "N of M" prose value extracts N, and it normalizes stably', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.integer({ min: 1, max: 9999 }), fc.integer({ min: 1, max: 9999 }), (n, m) => {
|
||||
const parsed = phaseId.parsePhaseFromProse(`${n} of ${m}`);
|
||||
return (
|
||||
parsed.phase === String(n) &&
|
||||
phaseId.normalizePhaseName(parsed.phase) === phaseId.normalizePhaseName(String(n))
|
||||
);
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,6 +47,9 @@ const INSTALL_JS_PATH_HELPERS = [
|
||||
'resolveOpencodeConfigPath',
|
||||
'computePathPrefix',
|
||||
'normalizeInstallRelativePath',
|
||||
// #2088 (ADR-1239 upgrade 3): resolves the skills-install dir honoring a
|
||||
// skills-kind `home` override (e.g. Codex → $HOME/.agents/skills).
|
||||
'_resolveSkillsRootDir',
|
||||
];
|
||||
|
||||
describe('portability-vocab drift guard', () => {
|
||||
|
||||
@@ -325,11 +325,14 @@ describe('generate-dev-preferences command', () => {
|
||||
const result = runGsdTools(
|
||||
['generate-dev-preferences', '--analysis', analysisPath, '--raw'],
|
||||
tmpDir,
|
||||
{ CODEX_HOME: codexHome, GSD_RUNTIME: 'codex' }
|
||||
// #2088 (ADR-1239 upgrade 3): Codex skills resolve to $HOME/.agents/skills
|
||||
// (HOME-relative), so sandbox HOME to keep the dev-preferences write inside
|
||||
// the temp dir rather than the developer's real ~/.agents/skills.
|
||||
{ CODEX_HOME: codexHome, GSD_RUNTIME: 'codex', HOME: codexHome, USERPROFILE: codexHome }
|
||||
);
|
||||
assert.ok(result.success, `Failed: ${result.error}`);
|
||||
const out = JSON.parse(result.output);
|
||||
assert.strictEqual(out.command_path, path.join(codexHome, 'skills', 'gsd-dev-preferences', 'SKILL.md'));
|
||||
assert.strictEqual(out.command_path, path.join(codexHome, '.agents', 'skills', 'gsd-dev-preferences', 'SKILL.md'));
|
||||
assert.ok(fs.existsSync(out.command_path), 'runtime-aware output should be written');
|
||||
});
|
||||
|
||||
@@ -347,11 +350,12 @@ describe('generate-dev-preferences command', () => {
|
||||
const result = runGsdTools(
|
||||
['generate-dev-preferences', '--analysis', analysisPath, '--raw'],
|
||||
tmpDir,
|
||||
{ CODEX_HOME: codexHome, GSD_RUNTIME: 'codex-app' }
|
||||
// #2088: codex-app alias canonicalizes to codex → $HOME/.agents/skills.
|
||||
{ CODEX_HOME: codexHome, GSD_RUNTIME: 'codex-app', HOME: codexHome, USERPROFILE: codexHome }
|
||||
);
|
||||
assert.ok(result.success, `Failed: ${result.error}`);
|
||||
const out = JSON.parse(result.output);
|
||||
assert.strictEqual(out.command_path, path.join(codexHome, 'skills', 'gsd-dev-preferences', 'SKILL.md'));
|
||||
assert.strictEqual(out.command_path, path.join(codexHome, '.agents', 'skills', 'gsd-dev-preferences', 'SKILL.md'));
|
||||
});
|
||||
|
||||
test('uses runtime-aware skills dir for cline by default (#782)', () => {
|
||||
|
||||
@@ -12,28 +12,63 @@
|
||||
*
|
||||
* Invoked as a subprocess (the hook reads a JSON payload on stdin and has no
|
||||
* exported surface), so this exercises the real shipped hook end-to-end.
|
||||
*
|
||||
* F.I.R.S.T. design:
|
||||
* Fast — spawnSync is synchronous; scanner exits in <100ms for any input.
|
||||
* Isolated — each invocation is a fresh subprocess; no shared state.
|
||||
* Repeatable — no wall-clock assertion; the 30s safety-net timeout is 6x the
|
||||
* scanner's own internal 5s timer and is never tested against.
|
||||
* Tests assert on the scanner's RESULT (exit code + output shape),
|
||||
* never on timing.
|
||||
* Self-Val — assertions check exit===0 and output is empty or valid JSON.
|
||||
* Timely — written alongside the scanner (#1577); hardened for #2089.
|
||||
*/
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const path = require('node:path');
|
||||
const fc = require('./helpers/fast-check-setup.cjs');
|
||||
|
||||
const HOOK_PATH = path.join(__dirname, '..', 'hooks', 'gsd-read-injection-scanner.js');
|
||||
|
||||
/**
|
||||
* Run the scanner hook with a payload and return its result.
|
||||
*
|
||||
* Uses spawnSync (not execFileSync) so non-zero exits return a result object
|
||||
* rather than throwing — cleaner for property tests that assert on exit code.
|
||||
*
|
||||
* Non-serializable payloads (BigInt, circular refs, Symbol, undefined) are
|
||||
* SKIPPED: the scanner receives JSON via stdin, so these values can never
|
||||
* reach it. JSON.stringify throwing is a test-harness artifact (fc.anything()
|
||||
* generates values outside the JSON domain), not a scanner defect.
|
||||
*
|
||||
* The 30s safety-net timeout is NOT a test assertion. The scanner exits in
|
||||
* <100ms for any input; its own internal setTimeout(5000) guarantees exit
|
||||
* even if stdin never closes (impossible here — spawnSync's `input:` pipes
|
||||
* and closes stdin). The ceiling only catches a genuinely hung process (a
|
||||
* real defect) without racing the scanner's internal timer.
|
||||
*/
|
||||
function runHook(payload) {
|
||||
let input;
|
||||
try {
|
||||
const stdout = execFileSync(process.execPath, [HOOK_PATH], {
|
||||
input: JSON.stringify(payload),
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
return { exitCode: 0, stdout: stdout.trim() };
|
||||
} catch (err) {
|
||||
return { exitCode: err.status ?? 1, stdout: (err.stdout || '').toString().trim() };
|
||||
input = JSON.stringify(payload);
|
||||
} catch {
|
||||
return { exitCode: 0, stdout: '', skipped: true };
|
||||
}
|
||||
|
||||
const result = spawnSync(process.execPath, [HOOK_PATH], {
|
||||
input,
|
||||
encoding: 'utf-8',
|
||||
timeout: 30000,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
return {
|
||||
exitCode: result.status ?? 1,
|
||||
stdout: (result.stdout || '').trim(),
|
||||
signal: result.signal,
|
||||
};
|
||||
}
|
||||
|
||||
// Injection-shaped fragments so the regex-matching path is exercised, not just clean text.
|
||||
|
||||
@@ -171,36 +171,71 @@ const path = require('node:path');
|
||||
const reviewPath = path.resolve(__dirname, '..', 'gsd-core', 'workflows', 'review.md');
|
||||
const read = () => fs.readFileSync(reviewPath, 'utf-8');
|
||||
|
||||
describe('bug #687: agy print mode must be bounded via its native --print-timeout', () => {
|
||||
test('invokes agy with its own --print-timeout flag (not an external killer)', () => {
|
||||
assert.match(read(), /agy --print-timeout \d+s? -p "\$\(cat/,
|
||||
'review.md must cap agy through `agy --print-timeout <N> -p …` (the tool\'s own mechanism)');
|
||||
describe('bug #687 → #2073: agy print mode bounded by --print-timeout PAIRED with an external timeout', () => {
|
||||
// #687 established that agy print mode must be bounded (its native
|
||||
// --print-timeout, default 5m). #2073 superseded the "no external killer"
|
||||
// half of that contract with documentation:
|
||||
// - agy's own print-mode guidance says to PAIR --print-timeout with an
|
||||
// external terminal `timeout` ("Pair with the terminal timeout= so the
|
||||
// outer call doesn't cut the run short"), because --print-timeout cannot
|
||||
// fire before agy creates a session (a pre-session stall otherwise hangs
|
||||
// unbounded). The external cap is set HIGHER than --print-timeout so it
|
||||
// only backstops a stall, never cuts a healthy run.
|
||||
// - agy gained `--model` in ~1.0.3 (issue #3782's "no --model flag" note
|
||||
// was correct at the time, stale now); review.models.agy is passed as
|
||||
// --model so a pinned model that 404s has an escape hatch.
|
||||
// - the prompt is now a file reference: inline `-p "$(cat …)"` overflows
|
||||
// the exec arg list on a large review prompt (Linux MAX_ARG_STRLEN
|
||||
// 128 KB/single-arg → rc 126).
|
||||
|
||||
test('invokes agy with --print-timeout AND a paired external killer when available', () => {
|
||||
const c = read();
|
||||
assert.match(c, /--print-timeout \d+s?/, 'review.md must pass agy its native --print-timeout');
|
||||
// Capability probe for GNU `timeout` / macOS `gtimeout` (stock macOS has neither).
|
||||
assert.match(c, /command -v timeout/, 'review.md must probe for the `timeout` killer');
|
||||
assert.match(c, /command -v gtimeout/, 'review.md must probe for `gtimeout` (macOS Homebrew)');
|
||||
// The external cap (600s) is applied ahead of agy and is >= --print-timeout (540s).
|
||||
assert.match(c, /600 agy --print-timeout 540s/,
|
||||
'review.md must pair an external cap (600s) >= --print-timeout (540s) with agy (agy guidance)');
|
||||
});
|
||||
|
||||
test('discards partial output on non-zero exit so the fallback fires', () => {
|
||||
test('external cap is >= --print-timeout, and falls back to bare agy on macOS', () => {
|
||||
const c = read();
|
||||
const bound = c.match(/(\d+)\s+agy --print-timeout (\d+)s/);
|
||||
assert.ok(bound, 'review.md must encode the external-cap + --print-timeout pair');
|
||||
assert.ok(
|
||||
Number(bound[1]) >= Number(bound[2]),
|
||||
'external cap (seconds) must be >= --print-timeout (seconds) so it only backstops a stall',
|
||||
);
|
||||
// Graceful fallback when no external killer is available (stock macOS).
|
||||
assert.match(c, /else\n\s*agy --print-timeout/,
|
||||
'review.md must fall back to --print-timeout alone when no external killer is available (macOS)');
|
||||
});
|
||||
|
||||
test('uses a file-reference prompt, not inline "$(cat …)" (arg-list overflow, #2073)', () => {
|
||||
const c = read();
|
||||
assert.doesNotMatch(c, /agy[^\n]*-p "\$\(cat/,
|
||||
'review.md must not feed agy the prompt inline via "$(cat …)" — a large review prompt overflows the exec arg list (rc 126)');
|
||||
assert.match(c, /Read the file at \/tmp\/gsd-review-prompt-/,
|
||||
'review.md should pass agy a file-reference prompt (mirrors the Cursor block)');
|
||||
});
|
||||
|
||||
test('wires --model from review.models.agy (#2073 mode 2; agy gained --model in ~1.0.3)', () => {
|
||||
assert.match(read(), /--model "\$AGY_MODEL"/,
|
||||
'review.md must pass --model "$AGY_MODEL" when review.models.agy is set');
|
||||
});
|
||||
|
||||
test('discards partial output on non-zero exit so the fallback fires (#687)', () => {
|
||||
const c = read();
|
||||
assert.match(c, /_AGY_RC.*-ne 0/, 'review.md must check the agy exit code');
|
||||
assert.match(c, /: > \/tmp\/gsd-review-antigravity-/,
|
||||
'review.md must truncate the output file when agy timed out / failed');
|
||||
});
|
||||
|
||||
test('agy is bounded only by its own --print-timeout, not an external process killer', () => {
|
||||
const c = read();
|
||||
// Print-mode reviewers invoke the tool directly; agy must self-terminate via
|
||||
// --print-timeout, never via an external SIGKILL/timeout binary wrapped around it.
|
||||
assert.doesNotMatch(c, /-s KILL/, 'must not SIGKILL agy from the outside');
|
||||
// Any external timeout binary wrapping agy — `timeout 300s agy …`,
|
||||
// `gtimeout 300 agy …`, `timeout -s KILL 300 agy …`. The lookbehind keeps
|
||||
// agy's own `--print-timeout` flag from tripping it.
|
||||
assert.doesNotMatch(c, /(?<!print-)\bg?timeout[ \t]+[^\n]*\bagy\b/,
|
||||
'must not wrap agy in an external timeout binary — use its --print-timeout flag');
|
||||
assert.doesNotMatch(c, /kill -9 "\$_AGY/, 'must not use a kill -9 watchdog on agy');
|
||||
});
|
||||
|
||||
test('no unguarded bare "agy -p" invocation remains at line start', () => {
|
||||
// A bare `agy -p "$(cat …)"` with no cap was the original hang.
|
||||
assert.doesNotMatch(read(), /^agy -p "\$\(cat/m,
|
||||
'review.md must not invoke agy -p without --print-timeout');
|
||||
// A bare `agy -p …` with no cap was the original #687 hang.
|
||||
assert.doesNotMatch(read(), /^agy -p/m,
|
||||
'review.md must not invoke a bare `agy -p` unbounded at line start');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -389,6 +389,41 @@ test('ambient GSD workstream vars are stripped by the runner', () => {
|
||||
`expected final file-count chunking marker in stderr; STDERR:\n${r.stderr}`,
|
||||
);
|
||||
});
|
||||
|
||||
// #2088: install-heavy files (real installs) are weighted so they never all
|
||||
// land in one chunk — otherwise the unsharded targeted lane packs the whole
|
||||
// install surface into a single chunk that blows the 600s per-chunk backstop
|
||||
// on the slow Windows runner.
|
||||
test('install-heavy files carry more weight so they SPREAD across chunks (#2088)', () => {
|
||||
// 4 install-* files at weight 3 = 12 against a 6-weight cap → 2 chunks
|
||||
// (2 heavy each). The whole install load is never in a single chunk.
|
||||
const heavy = Array.from({ length: 4 }, (_, i) => `install-weighttest-${i}.test.cjs`);
|
||||
seed(tmpDir, heavy);
|
||||
const rh = runHarness(tmpDir, [], {
|
||||
RUN_TESTS_MAX_CMDLINE_CHARS: '100000',
|
||||
RUN_TESTS_MAX_FILES_PER_CHUNK: '6',
|
||||
RUN_TESTS_HEAVY_FILE_WEIGHT: '3',
|
||||
});
|
||||
assert.strictEqual(rh.status, 0, `heavy: expected zero exit; STDERR:\n${rh.stderr}`);
|
||||
assert.match(rh.stderr, /run-tests: chunk 1\/2 — 2 files/, `install-heavy files must split into 2 chunks; STDERR:\n${rh.stderr}`);
|
||||
assert.match(rh.stderr, /run-tests: chunk 2\/2 — 2 files/, `STDERR:\n${rh.stderr}`);
|
||||
});
|
||||
|
||||
test('light files of the same count stay in ONE chunk — split is weight-driven, not count-driven (#2088)', () => {
|
||||
// Same file COUNT (4) but LIGHT (weight 1 each): 4 < 6 cap → a single
|
||||
// chunk. Proves the split above is driven by install-heavy WEIGHT.
|
||||
const light = Array.from({ length: 4 }, (_, i) => `lightweighttest-${i}.test.cjs`);
|
||||
seed(tmpDir, light);
|
||||
const rl = runHarness(tmpDir, [], {
|
||||
RUN_TESTS_MAX_CMDLINE_CHARS: '100000',
|
||||
RUN_TESTS_MAX_FILES_PER_CHUNK: '6',
|
||||
RUN_TESTS_HEAVY_FILE_WEIGHT: '3',
|
||||
});
|
||||
assert.strictEqual(rl.status, 0, `light: expected zero exit; STDERR:\n${rl.stderr}`);
|
||||
// A single chunk emits NO `chunk N/M` split marker (it only prints when
|
||||
// chunks.length > 1), so its absence proves the 4 light files stayed together.
|
||||
assert.doesNotMatch(rl.stderr, /run-tests: chunk \d+\/\d+ — /, `4 light files must stay in one chunk (no split marker); STDERR:\n${rl.stderr}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shard partitioning CLI (#1212)', () => {
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
"remove-phase.md": 8513,
|
||||
"remove-workspace.md": 7551,
|
||||
"resume-project.md": 17270,
|
||||
"review.md": 43356,
|
||||
"review.md": 46297,
|
||||
"scan.md": 7732,
|
||||
"secure-phase.md": 13520,
|
||||
"session-report.md": 4044,
|
||||
|
||||
Reference in New Issue
Block a user