From a27cb6b2fa3487c71a7332219ae6845708403f77 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Tue, 8 Sep 2026 14:31:04 -0400 Subject: [PATCH] =?UTF-8?q?enhance(#4139):=20Phase=206=20=E2=80=94=20the?= =?UTF-8?q?=20lazily-read=20remainder=20and=20the=20artifact=20templates?= =?UTF-8?q?=20(#4540)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * enhance(#4406): the lazily-read remainder and the artifact templates ADR-4139 Decision 3, Phase 6 of the #4139 Compact Content epic. Covers stream 1b (gsd-core/workflows//{modes,steps,templates}/*.md) and stream 4 (gsd-core/templates/**) with a variant-swap mechanism, confirmed with the user: two independent, complete files per covered path (canonical + .compact.md sibling), with the gate picking which one gets Read at the call site. This is a different shape from Phase 5's spine+detail partition, and is safe here specifically because these files are already reached only by a runtime Read — a missed Read already means zero overlay content today, with or without workflow.compact_content, so selecting between two independently-complete files introduces no new failure mode (documented in gsd-core/references/compact-content-gate.md's new "Streams 1b and 4" section). Disposition, after inspecting every candidate rather than trusting a byte-size threshold (same rigor Phase 5 applied to review.md): - Stream 1b: 1 of 78 files compacted (help/modes/full.md, a user-facing reference doc emitted verbatim, not orchestrator instruction). The other 9 size-threshold candidates are dominated by fail-closed guards, exact CLI invocations, or output-format contracts (AskUserQuestion blocks) — recorded not-worth-compacting, same reasoning as Phase 5's review.md. - Stream 4: a ground-truth reachability audit replaced the initial size-only candidate list. Two files (summary.md, user-setup.md) got compact variants; a third (spec.md) was drafted, then dropped after discovering its only two call sites are eager @-includes, not a runtime Read — stream-1 material hiding under gsd-core/templates/, not stream-4's actual mechanism. summary.md itself has 3 eager call sites and only 1 genuine runtime-Read call site (execute-plan.md); only that one was wired, so the compact variant's savings apply to the sequential single-plan execution path only. - Discovered while auditing reachability: 12 gsd-core/templates/** files with zero references anywhere in workflow/agent/command prose, compiled source, or tests — dead scaffolding predating this phase. Deleted in this same PR per this repo's no-defer policy, after re-verifying against a computed path.join(...) pattern (not just a plain-string search) that nearly caused two genuinely load-bearing templates (user-profile.md, dev-preferences.md) to be misclassified as dead. New checker (tests/helpers/compact-content-variant.cjs): registration, reachability, protected-content-preserved, size-smaller — replacing Phase 3/5's disjointness/completeness checks, which assume a partition rather than two deliberately-overlapping documents. The reachability check's own "unprefixed match" guard had a real bug (rejected the repo's own `~/.claude/gsd-core/...` convention), caught by running it against the already-wired help/modes/full.compact.md pair rather than only synthetic fixtures — fixed to anchor on the nearest `gsd-core` path segment instead. Template consumer parity (tests/compact-content-template-variant-parity.test.cjs): proves each compact variant's `## File Template` fenced block — the actual output-format contract a generated SUMMARY.md/USER-SETUP.md is parsed against — is byte-identical to the canonical file, then runs the one real deterministic consumer (gsd-core/bin/lib/coverage.cjs's classifyContent, backing `gsd-tools uat classify-coverage`) against content built from that shared contract. Added a sibling benchmark script (scripts/benchmark-compact-content-variants.cjs) rather than extending the existing spine/detail one — different data shape, and the existing script's own contract deliberately isolates it from a test-only helper's shape changing. Emitted-drift acknowledgement: not needed. Every changed/added path in this diff is hand-authored and present in the diff itself, so diffEmitted's attribution loop resolves `via` to the path's own source before reaching the ack-lookup branch (same reasoning Phase 5 verified for its own diff). Co-Authored-By: Claude Sonnet 5 * enhance(#4406): address code-review findings on the variant-swap gate - docs/CONFIGURATION.md and gsd-core/references/planning-config.md's workflow.compact_content entries described only the spine+detail mechanism (Phase 5) and were missing this phase's variant-swap mechanism and its benchmark:compact-content-variants script entirely — required since this PR's changeset is type Added (CLAUDE.md's "Missing Docs for Changesets" rule). Both now describe both mechanisms and which call sites are wired. - Added the missing RED^-1/no-op fixture for checkProtectedContentPreserved: a canonical file with zero blocks must be a no-op, not a violation — the only branch of that function the existing fixtures didn't exercise. - Collapsed findCompactFiles/findMarkdownFiles in tests/helpers/compact-content-variant.cjs into one findFilesWithSuffix helper — the two were identical recursive walks differing only in the extension predicate (minor Duplicated-Code finding). Co-Authored-By: Claude Sonnet 5 * fix(#4406): restore copilot-instructions.md, a false-positive dead-template classification gsd-test caught this, not static analysis: 10 real failures in tests/copilot-install.test.cjs, tests/installer-migration-install.integration.test.cjs, and tests/repo-layout.test.cjs — all downstream of bin/install.js's Copilot install path, which does fs.readFileSync(path.join(targetDir, 'gsd-core', 'templates', 'copilot-instructions.md')) after copying gsd-core/templates/** into the target project, then merges it into both .github/copilot-instructions.md and (local installs) AGENTS.md. The reachability audit that flagged this file as dead checked src/*.cts and gsd-core/bin/*.cjs but never the repo-root bin/install.js — a separately maintained installer bundle outside the src/-to-gsd-core/bin/lib/ compiled-output convention. The fs.existsSync guard around that read degrades to a silent skip rather than a crash when the template is missing, which is why this surfaced only once the real E2E install test ran, not from any static check. Re-verified the remaining 11 deleted filenames against bin/install.js specifically (plain substring and quoted-filename search) before trusting that list — all 11 have zero hits there, confirmed dead by the same standard this one file failed. Regenerated the installer emitted-tree goldens (tests/fixtures/install-tree/*.json) to reflect the restored file, and corrected the "Removed" changeset (jolly-lynx-sprint.md) and the phase design doc from 12 to 11 deleted files. Co-Authored-By: Claude Sonnet 5 Emitted-Drift-Ack-Growth: execute-plan.md — call-site wiring for the summary.md and user-setup.md .compact.md variants Emitted-Drift-Ack-Growth: help.md — call-site wiring for full.compact.md, same variant-resolution rule * docs(#4406): backfill changeset PR numbers Co-Authored-By: Claude Sonnet 5 * fix(#4406): resolve removed-but-needed lint findings on the dead-template deletion CI's own full-test matrix (not gsd-test's matrix, which does not run this check) caught 4 more false-positive dead-template classifications via tests/removed-but-needed-lint.test.cjs / scripts/lint-removed-but-needed.cjs — a literal, word-boundary basename check across .github/workflows/, gsd-core/, and docs/ (excluding docs/adr/** and docs/research/**) for every file a PR deletes. It has no semantic awareness, so a deleted template's basename colliding with something else entirely still fires: - claude-md.md: gsd-core/templates/README.md had a stale table row claiming /gsd-profile reads this template to generate CLAUDE.md. Verified false (no code reads it anywhere, same search that already covered bin/install.js) — fixed the row to *(inline)*, matching every other command-generated artifact in that table. File stays deleted. - codebase/testing.md: collided with docs/guides/testing.md, an illustrative example row in docs-update.md's sample output table (an unrelated real generated-docs path). Swapped the example topic to "contributing" — the row is illustrative, any topic works. File stays deleted. - codebase/architecture.md, codebase/stack.md: collided with docs/reference/ planning-artifacts.md's directory listing of a user's own generated .planning/codebase/architecture.md and stack.md output — the same semantic mismatch already investigated and dismissed as unrelated earlier in this phase's audit, now caught by a gate instead of judgment. That listing repeats across 5 locale copies of the doc. - continue-here.md: collided with the real .continue-here.md pause-work artifact, referenced across 15+ locale and workflow files. For the last two, the lint's own error message offers "restore the file or update every consumer in the same commit." Rewording 15+ files across languages I cannot verify translation quality for, to shave 2 already-tiny templates that were merely presumed dead, is disproportionate to this PR's actual scope — restored codebase/architecture.md, codebase/stack.md, and continue-here.md instead, and corrected docs/ARCHITECTURE.md's Templates section accordingly. Final confirmed-dead set: claude-md.md, codebase/concerns.md, codebase/conventions.md, codebase/integrations.md, codebase/structure.md, codebase/testing.md, debug-subagent-prompt.md, discovery.md — 8 files, down from the original 12. Verified locally: GSD_REMOVED_BUT_NEEDED_BASE=next node scripts/lint-removed-but-needed.cjs now passes clean. Co-Authored-By: Claude Sonnet 5 Emitted-Drift-Ack-Growth: docs-update.md — swapped an illustrative example-table topic (testing -> contributing) to avoid a removed-but-needed basename collision with the deleted codebase/testing.md template; net +10 bytes * fix(#4406): split codex-config.test.cjs to fix a genuine Windows CI timeout Root cause of the `full test (windows-latest, 24, shard 2/3)` failure the user asked to be actually fixed, not just re-run past: PR #4497 (landed 2026-09-07, one day before this PR's CI run) isolated tests/codex-config.test.cjs into its own dedicated chunk because its measured weight (17.87, ~45% of the post-cut Windows budget) made it unsafe to share a chunk with any other file. That isolation was necessary but not sufficient — even alone, with zero companion-file contention, the file's real Windows execution time sits right at the 600s per-chunk ceiling. Two independent CI runs on two unrelated PRs (this one and #4154) were both killed within ~1.4s of the identical 600000ms mark — not random contention, a deterministic near-miss the isolation fix couldn't address because it never reduced the file's own cost, only removed the risk of a companion file's cost stacking on top of it (which the PR #4497 comment explicitly anticipated: "if a future profiling pass genuinely speeds up codex-config.test.cjs itself, this isolation can be revisited"). The file itself explains why it's this heavy: 11,262 lines / 433 tests / 79 describe blocks, accumulated over dozens of bug-fix PRs (#2695, #2760, #3245, #3285, #3346, #3426, #3427, #3562, #3566, #3582, #3808, and more), several of which are explicitly documented as "folded" in from separate files that were never actually split back out ("Verified non-duplicate against both the pre-existing target and the other three folded sources"). Split into 4 files by top-level AST statement boundaries (never a naive column-0 regex — an early attempt at that overcounted 79 apparent "describe(" matches when only 21 are genuinely top-level; the rest are nested inside a handful of large folded-in blocks, which a regex can't tell apart from real top-level statements). Verified lossless twice: the split script asserts byte-for-byte reconstruction of every source character, and independently, total test()/describe() call counts match exactly between the original file and the sum across all 4 new files (433/79 both sides). Each new file carries the complete original shared header (imports/helpers) for safety; per-file unused-import warnings from that duplication are resolved via ESLint-precise alias renames (`{ foo: _foo }`, the standard form for an intentionally-unused destructured binding — never a bare `{ _foo }`, which would destructure a different, nonexistent property). No change needed to scripts/run-tests.cjs's ISOLATED_HEAVY_FILES or its pinned test in tests/run-tests-harness.test.cjs: the file that keeps the original name (tests/codex-config.test.cjs) is now only ~28% of the original's size and safely isolated in its own chunk as before; the other three new files re-enter normal weight-balanced packing, none individually close to disproportionate. Confirmed no other file hardcodes the hardcoded filename anywhere that would silently stop these tests from running (the CI test-selection scripts determine scope algorithmically, not by literal filename). Co-Authored-By: Claude Sonnet 5 --------- Co-authored-by: sim Co-authored-by: Claude Sonnet 5 --- .changeset/jolly-lynx-sprint.md | 5 + .changeset/patient-moles-click.md | 5 + docs/ARCHITECTURE.md | 2 +- docs/CONFIGURATION.md | 2 +- docs/INVENTORY-MANIFEST.json | 1 + gsd-core/references/compact-content-gate.md | 40 +- gsd-core/references/planning-config.md | 2 +- gsd-core/templates/README.md | 2 +- gsd-core/templates/claude-md.md | 145 - gsd-core/templates/codebase/concerns.md | 310 - gsd-core/templates/codebase/conventions.md | 307 - gsd-core/templates/codebase/integrations.md | 280 - gsd-core/templates/codebase/structure.md | 285 - gsd-core/templates/codebase/testing.md | 480 - gsd-core/templates/debug-subagent-prompt.md | 91 - gsd-core/templates/discovery.md | 146 - gsd-core/templates/summary.compact.md | 212 + gsd-core/templates/user-setup.compact.md | 199 + gsd-core/workflows/docs-update.md | 2 +- gsd-core/workflows/execute-plan.md | 4 +- gsd-core/workflows/help.md | 2 +- gsd-core/workflows/help/modes/full.compact.md | 398 + package.json | 1 + .../benchmark-compact-content-variants.cjs | 291 + tests/codex-config-agents.test.cjs | 2350 +++++ tests/codex-config-hooks.test.cjs | 3629 +++++++ tests/codex-config-install.test.cjs | 2894 ++++++ tests/codex-config.test.cjs | 8315 +---------------- ...t-content-template-variant-parity.test.cjs | 142 + tests/compact-content-variant-guard.test.cjs | 293 + .../compact-content-benchmark-baseline.json | 8 +- ...ct-content-variant-benchmark-baseline.json | 31 + tests/fixtures/install-tree/antigravity.json | 11 +- tests/fixtures/install-tree/augment.json | 11 +- tests/fixtures/install-tree/claude-local.json | 11 +- tests/fixtures/install-tree/claude.json | 11 +- tests/fixtures/install-tree/cline.json | 11 +- tests/fixtures/install-tree/codebuddy.json | 11 +- tests/fixtures/install-tree/codex.json | 11 +- tests/fixtures/install-tree/copilot.json | 11 +- tests/fixtures/install-tree/cursor.json | 11 +- tests/fixtures/install-tree/hermes.json | 11 +- tests/fixtures/install-tree/kilo.json | 11 +- tests/fixtures/install-tree/kimi-code.json | 11 +- tests/fixtures/install-tree/kimi.json | 11 +- tests/fixtures/install-tree/opencode.json | 11 +- tests/fixtures/install-tree/pi.json | 11 +- tests/fixtures/install-tree/qwen.json | 11 +- tests/fixtures/install-tree/trae.json | 11 +- tests/fixtures/install-tree/windsurf.json | 11 +- tests/fixtures/install-tree/zcode.json | 11 +- tests/helpers/compact-content-variant.cjs | 293 + 52 files changed, 10865 insertions(+), 10511 deletions(-) create mode 100644 .changeset/jolly-lynx-sprint.md create mode 100644 .changeset/patient-moles-click.md delete mode 100644 gsd-core/templates/claude-md.md delete mode 100644 gsd-core/templates/codebase/concerns.md delete mode 100644 gsd-core/templates/codebase/conventions.md delete mode 100644 gsd-core/templates/codebase/integrations.md delete mode 100644 gsd-core/templates/codebase/structure.md delete mode 100644 gsd-core/templates/codebase/testing.md delete mode 100644 gsd-core/templates/debug-subagent-prompt.md delete mode 100644 gsd-core/templates/discovery.md create mode 100644 gsd-core/templates/summary.compact.md create mode 100644 gsd-core/templates/user-setup.compact.md create mode 100644 gsd-core/workflows/help/modes/full.compact.md create mode 100644 scripts/benchmark-compact-content-variants.cjs create mode 100644 tests/codex-config-agents.test.cjs create mode 100644 tests/codex-config-hooks.test.cjs create mode 100644 tests/codex-config-install.test.cjs create mode 100644 tests/compact-content-template-variant-parity.test.cjs create mode 100644 tests/compact-content-variant-guard.test.cjs create mode 100644 tests/fixtures/compact-content-variant-benchmark-baseline.json create mode 100644 tests/helpers/compact-content-variant.cjs diff --git a/.changeset/jolly-lynx-sprint.md b/.changeset/jolly-lynx-sprint.md new file mode 100644 index 000000000..8d3cc3782 --- /dev/null +++ b/.changeset/jolly-lynx-sprint.md @@ -0,0 +1,5 @@ +--- +type: Removed +pr: 4540 +--- +**Removed 8 unreferenced planning-artifact scaffolding templates under `gsd-core/templates/`** (`claude-md.md`, four of the seven `codebase/` brownfield-mapping templates — `concerns.md`, `conventions.md`, `integrations.md`, `structure.md` — plus `debug-subagent-prompt.md` and `discovery.md`) — confirmed, file by file, to have zero references anywhere in workflow prose, agent/command definitions, compiled source, or tests, and (for the deleted set specifically) no surviving basename reference anywhere in the tree either. `codebase/architecture.md`, `codebase/stack.md`, and `continue-here.md` were kept: their basenames collide with unrelated, genuinely live concepts documented across many files (a user's generated `.planning/codebase/*.md` output, and the real `.continue-here.md` pause-work artifact), so deleting them would have required rewording numerous translated docs to describe something else entirely. diff --git a/.changeset/patient-moles-click.md b/.changeset/patient-moles-click.md new file mode 100644 index 000000000..b263315af --- /dev/null +++ b/.changeset/patient-moles-click.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 4540 +--- +**`workflow.compact_content` now also covers lazily-read workflow fragments and planning-artifact templates.** With the key on, `help --full`'s reference doc and generated `SUMMARY.md`/`USER-SETUP.md` templates resolve to a terser `.compact.md` sibling at the point of their existing `Read` — two independent, complete files, picked per the same shared gate Phase 5 introduced (`gsd-core/references/compact-content-gate.md`). With the key off (default), nothing changes. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 068509313..2a8a272f9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -277,7 +277,7 @@ Markdown templates for all planning artifacts. Used by `gsd-tools.cjs template f - `DEBUG.md` — Debug session tracking template - `UI-SPEC.md`, `UAT.md`, `VALIDATION.md` — Specialized verification templates - `discussion-log.md` — Discussion audit trail template -- `codebase/` — Brownfield mapping templates (stack, architecture, conventions, concerns, structure, testing, integrations) +- `codebase/` — Brownfield mapping templates (architecture, stack) - `research-project/` — Research output templates (SUMMARY, STACK, FEATURES, ARCHITECTURE, PITFALLS) ### Hooks (`hooks/`) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 90ae0cdcc..aa9c29bb5 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -517,7 +517,7 @@ All workflow toggles follow the **absent = enabled** pattern. If a key is missin | `workflow.text_mode` | boolean | `false` | Replaces AskUserQuestion TUI menus with plain-text numbered lists. Required for Claude Code remote sessions (`/rc` mode) where TUI menus don't render. Can also be set per-session with `--text` flag on discuss-phase. Added in v1.28 | | `workflow.use_worktrees` | boolean | `true` | When `false`, disables git worktree isolation for parallel execution. Users who prefer sequential execution or whose environment does not support worktrees can disable this. Added in v1.31. **Branch-divergence note:** when your branch has diverged from `origin/HEAD`, GSD auto-degrades to sequential and prints a warning. See [`worktree.baseRef`](#worktree-settings) to restore parallel execution on a diverged branch. **Per-runtime note:** whether this key can be honored depends on the runtime's declared `dispatch.isolation` capability, not on its name (#2584). Runtimes whose own harness isolates each executor (**Claude Code**, **Cursor**) run parallel worktrees natively; runtimes exposing a headless exec with an explicit working directory (**Codex**, **OpenCode**, **Kimi**, **Kimi Code**) get worktrees GSD itself creates and merges — where a dispatch site can only drive the harness model, those hosts degrade to sequential with a warning rather than aborting. Every other runtime declares no isolation primitive, and forcing `use_worktrees: true` there still fails closed before any executor dispatch. `/gsd-health` reports such a value as warning `W025` (#2486). **Default on a non-Claude install:** if a worktree-capable non-Claude host is not isolating as described above, check whether the install stamped this key's default to `false` and set an explicit `use_worktrees: true`. See [Executor isolation per runtime](#executor-isolation-per-runtime). | | `workflow.agent_hint_routing` | boolean | `true` | Per-plan specialist executor routing (#1689). When `true`, a plan whose `agent_hint:` frontmatter names a subagent that resolves on the active runtime is dispatched to that specialist instead of `gsd-executor`. Default `true` — a no-op for plans without `agent_hint:`, so existing dispatch is unchanged. Set `false` to disable. See [PLAN.md `agent_hint`](reference/plan-md.md#per-plan-executor-routing). | -| `workflow.compact_content` | boolean | `false` | Compact content mode (#4139, [ADR-4139](adr/4139-compact-content-seam.md)). Per-project boolean selecting the terser form of GSD's own shipped prompt content (workflows, templates, agent-skill payloads). Six workflows branch on it today — `plan-phase` (#4402, the pilot), `execute-phase`, `docs-update`, `new-project`, `verify-work`, and `complete-milestone` (#4405) — each split into a spine plus a deferred `/detail/*.md` elaboration: with the key off, the spine reads its own elaboration back in before continuing (byte-identical instruction set to before); with it on, that read is skipped. The remaining eagerly-`@`-included workflows were reviewed and recorded as not worth splitting (see `docs/PARTITION-RULES.md` § "Deciding whether a file is worth splitting") — either their size comes from safety-critical orchestration logic rather than deferrable narrative (`review.md`), or they're small enough that a split's fixed structural overhead would exceed the savings. The eager-window token reduction each split actually achieves is measured, not asserted: `npm run benchmark:compact-content` reports per-split and aggregate on/off token counts (a proxy-tokenizer delta — Anthropic publishes no tokenizer for Claude 3+, so the comparison is exact under a pinned tokenizer even though the absolute counts are not Claude's real ones) against a committed baseline (`tests/fixtures/compact-content-benchmark-baseline.json`, #4404). Reporting-only — it never fails CI. | +| `workflow.compact_content` | boolean | `false` | Compact content mode (#4139, [ADR-4139](adr/4139-compact-content-seam.md)). Per-project boolean selecting the terser form of GSD's own shipped prompt content (workflows, templates, agent-skill payloads). Two mechanisms exist, chosen per stream. **Spine + detail** (top-level, eagerly-`@`-included workflows): six workflows branch on it today — `plan-phase` (#4402, the pilot), `execute-phase`, `docs-update`, `new-project`, `verify-work`, and `complete-milestone` (#4405) — each split into a spine plus a deferred `/detail/*.md` elaboration: with the key off, the spine reads its own elaboration back in before continuing (byte-identical instruction set to before); with it on, that read is skipped. The remaining eagerly-`@`-included workflows were reviewed and recorded as not worth splitting (see `docs/PARTITION-RULES.md` § "Deciding whether a file is worth splitting") — either their size comes from safety-critical orchestration logic rather than deferrable narrative (`review.md`), or they're small enough that a split's fixed structural overhead would exceed the savings. **Variant swap** (#4406 — lazily-`Read` workflow subdirectory files and `gsd-core/templates/**` planning-artifact templates, which have no eager window to shrink): a `.compact.md` sibling next to the canonical file, resolved at the point of the existing `Read` per `gsd-core/references/compact-content-gate.md` § "Streams 1b and 4". Three call sites are wired today — `help --full`'s reference doc (`gsd-core/workflows/help/modes/full.md`) and the sequential-execution `SUMMARY.md`/`USER-SETUP.md` template reads in `execute-plan.md` — after a per-candidate reachability audit found most other size-based candidates were either genuinely unreferenced (deleted), reached only through an eager `@`-include or orchestrator build-time embed (left unconverted, same reasoning as the eagerly-included workflows above), or consumed only by a test fixture or a parser's documented grammar rather than a runtime `Read`. The token reduction each mechanism actually achieves is measured, not asserted: `npm run benchmark:compact-content` (spine/detail) and `npm run benchmark:compact-content-variants` (variant-swap) each report per-item and aggregate on/off token counts (a proxy-tokenizer delta — Anthropic publishes no tokenizer for Claude 3+, so the comparison is exact under a pinned tokenizer even though the absolute counts are not Claude's real ones) against their own committed baseline (`tests/fixtures/compact-content-benchmark-baseline.json`, #4404; `tests/fixtures/compact-content-variant-benchmark-baseline.json`, #4406). Both are reporting-only — neither ever fails CI. | | `workflow.worktree_skip_hooks` | boolean | `false` | When `true`, executor agents in worktree mode pass `--no-verify` (skipping pre-commit hooks) and post-wave hook validation runs against the merged result instead. Opt-in escape hatch for projects whose hooks cannot run in agent worktrees. Default `false` runs hooks on every commit (#2924). | | `workflow.code_review` | boolean | `true` | Enable `/gsd-code-review` and `/gsd-code-review --fix` commands. When `false`, the commands exit with a configuration gate message. Added in v1.34 | | `workflow.code_review_point` | string | `execute:post` | Loop point at which the code-review capability's step registers: `execute:post` reviews once, after every wave in a phase has landed (default — unchanged behavior); `execute:wave:post` reviews once per completed wave instead, scoped to what changed since the phase's prior review (the whole phase's diff on the first wave, each subsequent wave's own diff thereafter). Manual `/gsd-code-review ` invocation is unaffected by this key — it is gated by `workflow.code_review` alone and runs regardless of which point is configured. `/gsd-autonomous` and `/gsd-quick` have no wave granularity of their own, so setting this to `execute:wave:post` means code review does not run automatically inside those two flows (consistent with how every other `execute:wave:post`-only capability already behaves for them). Added in #3661 | diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 33cdc9651..a134da482 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -603,6 +603,7 @@ "discuss-phase/modes/text.md", "help/modes/brief.md", "help/modes/default.md", + "help/modes/full.compact.md", "help/modes/full.md", "help/modes/topic.md" ], diff --git a/gsd-core/references/compact-content-gate.md b/gsd-core/references/compact-content-gate.md index f582e3ab3..e31b38a9b 100644 --- a/gsd-core/references/compact-content-gate.md +++ b/gsd-core/references/compact-content-gate.md @@ -1,6 +1,9 @@ # Compact Content Gate -Shared by every workflow spine split under ADR-4139. States the config check and the resolution rule once — a spine references this file; it never restates the check inline. +Shared by every workflow spine split under ADR-4139, and by every lazily-read fragment or +planning-artifact template given a compact variant under Phase 6 (#4406). States the config check +and both resolution rules once — a spine or fragment references this file; it never restates +either check inline. ## The check @@ -8,11 +11,40 @@ Shared by every workflow spine split under ADR-4139. States the config check and COMPACT_CONTENT=$(gsd_run query config-get workflow.compact_content --raw 2>/dev/null || echo "false") ``` -## The resolution rule +## Stream 1 — spine + detail (top-level, eagerly `@`-included workflows) - **`COMPACT_CONTENT` is `"false"` (default):** Read every part under this workflow's own `detail/` directory (a sibling of this spine, e.g. `gsd-core/workflows//detail/*.md`) now, in full, before continuing past this point. Their content elaborates on the spine you are reading — treat everything they say as part of this document from here on. - **`COMPACT_CONTENT` is `"true"`:** Do not read the detail file. Continue directly with the spine's own content — per ADR-4139 Decision 3, it is complete enough to run this workflow correctly on its own. -## The fail-safe this exists to hold (ADR-4139 Decision 4) +**The fail-safe this holds (ADR-4139 Decision 4):** A `Read` that does not fire for any reason (tool error, a skipped step, a misread condition) leaves you running on the spine alone. That is the same, correct, terser state an opted-in project runs in on purpose — never a state with no instructions. The spine's own completeness is what makes this safe; this gate is only ever additive. -A `Read` that does not fire for any reason (tool error, a skipped step, a misread condition) leaves you running on the spine alone. That is the same, correct, terser state an opted-in project runs in on purpose — never a state with no instructions. The spine's own completeness is what makes this safe; this gate is only ever additive. +## Streams 1b and 4 — variant resolution (lazily-read fragments and planning-artifact templates) + +For a `workflows//{modes,steps,templates}/*.md` fragment or a `gsd-core/templates/**` +planning-artifact template that has a registered `.compact.md` sibling (same directory, same stem, +`.compact.md` suffix): + +- **`COMPACT_CONTENT` is `"false"` (default), or the file has no registered `.compact.md` sibling:** Read the canonical path exactly as named — unchanged from today. +- **`COMPACT_CONTENT` is `"true"` and a `.compact.md` sibling is registered:** Read the `.compact.md` sibling instead of the canonical path. + +Both files are complete, independently — Read exactly one, never both, and never read the compact +sibling's content as an addendum to the canonical file. + +**The fail-safe this holds:** unlike stream 1, a call site this rule actually applies to is already +reached only by a runtime `Read` — a missed `Read` already means zero overlay content, with or +without `workflow.compact_content`. Selecting between two independently-complete files at that +call site does not introduce a new way to end up with nothing; the worst case is identical to +today's. This is why stream 1b/4 can use variant-swap (two independent files) where stream 1 could +not: the degradation-direction argument that ruled out converting stream 1's `@`-includes (ADR-4139 +Decision 4) does not apply at a genuine runtime-`Read` call site, because there is no +host-guaranteed baseline being traded away there. + +**This rule is scoped per call site, not per file.** A `gsd-core/templates/**` file can have both +kinds of reference in the corpus at once — some places name it inside an eager `@`-include or an +orchestrator build-time embed (the same mechanism as stream 1, just reaching a template path +instead of a workflow path), others name it in prose instructing a runtime `Read`. Only the latter +gets rewritten to point at this rule; an eager reference to the canonical file is left exactly as +it is, for the same reason stream 1's `@`-includes were left alone — converting it would trade a +host-guaranteed load for a conditional one. Before wiring any call site, confirm by inspection +which kind it is; do not assume every mention of a `gsd-core/templates/**` path is a runtime `Read` +just because the directory's typical case is. diff --git a/gsd-core/references/planning-config.md b/gsd-core/references/planning-config.md index 48b40eabe..c0bd80aa2 100644 --- a/gsd-core/references/planning-config.md +++ b/gsd-core/references/planning-config.md @@ -289,7 +289,7 @@ Set via `workflow.*` namespace in config.json (e.g., `"workflow": { "research": | `workflow.ui_phase` | boolean | `true` | `true`, `false` | Generate UI-SPEC.md for frontend phases | | `workflow.ui_safety_gate` | boolean | `true` | `true`, `false` | Require safety gate approval for UI changes | | `workflow.text_mode` | boolean | `false` | `true`, `false` | Use plain-text numbered lists instead of AskUserQuestion menus | -| `workflow.compact_content` | boolean | `false` | `true`, `false` | Compact content mode (#4139, ADR-4139) — per-project boolean selecting terser payloads. Six workflows branch on it: `plan-phase` (#4402, pilot), `execute-phase`, `docs-update`, `new-project`, `verify-work`, `complete-milestone` (#4405). The rest of the eager-window corpus was reviewed and recorded as not worth splitting (`docs/PARTITION-RULES.md`) | +| `workflow.compact_content` | boolean | `false` | `true`, `false` | Compact content mode (#4139, ADR-4139) — per-project boolean selecting terser payloads. Six workflows branch on it via spine+detail: `plan-phase` (#4402, pilot), `execute-phase`, `docs-update`, `new-project`, `verify-work`, `complete-milestone` (#4405). The rest of the eager-window corpus was reviewed and recorded as not worth splitting (`docs/PARTITION-RULES.md`). Lazily-`Read` workflow fragments and `gsd-core/templates/**` templates use a `.compact.md` sibling instead (#4406, `gsd-core/references/compact-content-gate.md` § "Streams 1b and 4") — wired today for `help --full` and the sequential-execution `SUMMARY.md`/`USER-SETUP.md` reads | | `workflow.research_before_questions` | boolean | `false` | `true`, `false` | Run research before interactive questions in discuss phase (also honored on the `/gsd:quick` path, #3894). _Alias:_ `research_before_questions` is the flat-key form used in `CONFIG_DEFAULTS`; `workflow.research_before_questions` is the canonical namespaced form. | | `workflow.discuss_mode` | string | `"discuss"` | `"discuss"`, `"assumptions"` | Default mode for discuss-phase: `"discuss"` runs interactive questioning; `"assumptions"` analyzes codebase and surfaces assumptions instead | | `workflow.skip_discuss` | boolean | `false` | `true`, `false` | Skip discuss phase entirely | diff --git a/gsd-core/templates/README.md b/gsd-core/templates/README.md index acd8f0183..0968235b6 100644 --- a/gsd-core/templates/README.md +++ b/gsd-core/templates/README.md @@ -21,7 +21,7 @@ These files live directly at `.planning/` — not inside phase subdirectories. | `LEARNINGS.md` | *(inline)* | `/gsd:extract-learnings`, `/gsd:execute-phase` (gated: `features.global_learnings`) | Phase retrospective learnings for future plans | | `THREADS.md` | *(inline)* | `/gsd:thread` | Persistent discussion threads | | `config.json` | `config.json` | `/gsd:new-project`, `/gsd:health --repair` | Project-specific GSD configuration | -| `CLAUDE.md` | `claude-md.md` | `/gsd-profile` | Auto-assembled Claude Code context file | +| `CLAUDE.md` | *(inline)* | `/gsd-profile` | Auto-assembled Claude Code context file | | `RETROSPECTIVE.md` | *(inline)* | `/gsd:complete-milestone` | Living milestone retrospective updated at each milestone close | ### Version-stamped artifacts (pattern: `vX.Y-*.md`) diff --git a/gsd-core/templates/claude-md.md b/gsd-core/templates/claude-md.md deleted file mode 100644 index 4c96fd488..000000000 --- a/gsd-core/templates/claude-md.md +++ /dev/null @@ -1,145 +0,0 @@ -# CLAUDE.md Template - -Template for project-root `CLAUDE.md` — auto-generated by `gsd-tools generate-claude-md`. - -Contains 7 marker-bounded sections. Each section is independently updatable. -The `generate-claude-md` subcommand manages 6 sections (project, stack, conventions, architecture, skills, workflow enforcement). -The profile section is managed exclusively by `generate-claude-profile`. - ---- - -## Section Templates - -### Project Section -``` - -## Project - -{{project_content}} - -``` - -**Fallback text:** -``` -Project not yet initialized. Run /gsd:new-project to set up. -``` - -### Stack Section -``` - -## Technology Stack - -{{stack_content}} - -``` - -**Fallback text:** -``` -Technology stack not yet documented. Will populate after codebase mapping or first phase. -``` - -### Conventions Section -``` - -## Conventions - -{{conventions_content}} - -``` - -**Fallback text:** -``` -Conventions not yet established. Will populate as patterns emerge during development. -``` - -### Architecture Section -``` - -## Architecture - -{{architecture_content}} - -``` - -**Fallback text:** -``` -Architecture not yet mapped. Follow existing patterns found in the codebase. -``` - -### Skills Section -``` - -## Project Skills - -| Skill | Description | Path | -| -------------- | --------------------- | ------------------------- | -| {{skill_name}} | {{skill_description}} | `{{skill_path}}/SKILL.md` | - -``` - -**Fallback text:** -``` -No project skills found. Add skills to any of: `.claude/skills/`, `.agents/skills/`, `.cursor/skills/`, or `.github/skills/` with a `SKILL.md` index file. -``` - -**Discovery behavior:** -- Scans `.claude/skills/`, `.agents/skills/`, `.cursor/skills/`, `.github/skills/` for subdirectories containing `SKILL.md` -- Extracts `name` and `description` from YAML frontmatter (supports multi-line descriptions) -- Skips GSD's own installed skills (directories starting with `gsd-`) -- Deduplicates by skill name across directories - -### Workflow Enforcement Section -``` - -## GSD Workflow Enforcement - -Before using Edit, Write, or other file-changing tools, start work through a GSD command so planning artifacts and execution context stay in sync. - -Use these entry points: -- `/gsd:quick` for small fixes, doc updates, and ad-hoc tasks -- `/gsd:debug` for investigation and bug fixing -- `/gsd:execute-phase` for planned phase work - -Do not make direct repo edits outside a GSD workflow unless the user explicitly asks to bypass it. - -``` - -### Profile Section (Placeholder Only) -``` - -## Developer Profile - -> Profile not yet configured. Run `/gsd:profile-user` to generate your developer profile. -> This section is managed by `generate-claude-profile` — do not edit manually. - -``` - -**Note:** This section is NOT managed by `generate-claude-md`. It is managed exclusively -by `generate-claude-profile`. The placeholder above is only used when creating a new -CLAUDE.md file and no profile section exists yet. - ---- - -## Section Ordering - -1. **Project** — Identity and purpose (what this project is) -2. **Stack** — Technology choices (what tools are used) -3. **Conventions** — Code patterns and rules (how code is written) -4. **Architecture** — System structure (how components fit together) -5. **Skills** — Discovered project skills with name and description (what domain knowledge is available) -6. **Workflow Enforcement** — Default GSD entry points for file-changing work -7. **Profile** — Developer behavioral preferences (how to interact) - -## Marker Format - -- Start: `` -- End: `` -- Source attribute enables targeted updates when source files change -- Partial match on start marker (without closing `-->`) for detection - -## Fallback Behavior - -When a source file is missing, fallback text provides Claude-actionable guidance: -- Guides Claude's behavior in the absence of data -- Not placeholder ads or "missing" notices -- Each fallback tells Claude what to do, not just what's absent diff --git a/gsd-core/templates/codebase/concerns.md b/gsd-core/templates/codebase/concerns.md deleted file mode 100644 index c1ffcb420..000000000 --- a/gsd-core/templates/codebase/concerns.md +++ /dev/null @@ -1,310 +0,0 @@ -# Codebase Concerns Template - -Template for `.planning/codebase/CONCERNS.md` - captures known issues and areas requiring care. - -**Purpose:** Surface actionable warnings about the codebase. Focused on "what to watch out for when making changes." - ---- - -## File Template - -```markdown -# Codebase Concerns - -**Analysis Date:** [YYYY-MM-DD] - -## Tech Debt - -**[Area/Component]:** -- Issue: [What's the shortcut/workaround] -- Why: [Why it was done this way] -- Impact: [What breaks or degrades because of it] -- Fix approach: [How to properly address it] - -**[Area/Component]:** -- Issue: [What's the shortcut/workaround] -- Why: [Why it was done this way] -- Impact: [What breaks or degrades because of it] -- Fix approach: [How to properly address it] - -## Known Bugs - -**[Bug description]:** -- Symptoms: [What happens] -- Trigger: [How to reproduce] -- Workaround: [Temporary mitigation if any] -- Root cause: [If known] -- Blocked by: [If waiting on something] - -**[Bug description]:** -- Symptoms: [What happens] -- Trigger: [How to reproduce] -- Workaround: [Temporary mitigation if any] -- Root cause: [If known] - -## Security Considerations - -**[Area requiring security care]:** -- Risk: [What could go wrong] -- Current mitigation: [What's in place now] -- Recommendations: [What should be added] - -**[Area requiring security care]:** -- Risk: [What could go wrong] -- Current mitigation: [What's in place now] -- Recommendations: [What should be added] - -## Performance Bottlenecks - -**[Slow operation/endpoint]:** -- Problem: [What's slow] -- Measurement: [Actual numbers: "500ms p95", "2s load time"] -- Cause: [Why it's slow] -- Improvement path: [How to speed it up] - -**[Slow operation/endpoint]:** -- Problem: [What's slow] -- Measurement: [Actual numbers] -- Cause: [Why it's slow] -- Improvement path: [How to speed it up] - -## Fragile Areas - -**[Component/Module]:** -- Why fragile: [What makes it break easily] -- Common failures: [What typically goes wrong] -- Safe modification: [How to change it without breaking] -- Test coverage: [Is it tested? Gaps?] - -**[Component/Module]:** -- Why fragile: [What makes it break easily] -- Common failures: [What typically goes wrong] -- Safe modification: [How to change it without breaking] -- Test coverage: [Is it tested? Gaps?] - -## Scaling Limits - -**[Resource/System]:** -- Current capacity: [Numbers: "100 req/sec", "10k users"] -- Limit: [Where it breaks] -- Symptoms at limit: [What happens] -- Scaling path: [How to increase capacity] - -## Dependencies at Risk - -**[Package/Service]:** -- Risk: [e.g., "deprecated", "unmaintained", "breaking changes coming"] -- Impact: [What breaks if it fails] -- Migration plan: [Alternative or upgrade path] - -## Missing Critical Features - -**[Feature gap]:** -- Problem: [What's missing] -- Current workaround: [How users cope] -- Blocks: [What can't be done without it] -- Implementation complexity: [Rough effort estimate] - -## Test Coverage Gaps - -**[Untested area]:** -- What's not tested: [Specific functionality] -- Risk: [What could break unnoticed] -- Priority: [High/Medium/Low] -- Difficulty to test: [Why it's not tested yet] - ---- - -*Concerns audit: [date]* -*Update as issues are fixed or new ones discovered* -``` - - -```markdown -# Codebase Concerns - -**Analysis Date:** 2025-01-20 - -## Tech Debt - -**Database queries in React components:** -- Issue: Direct Supabase queries in 15+ page components instead of server actions -- Files: `app/dashboard/page.tsx`, `app/profile/page.tsx`, `app/courses/[id]/page.tsx`, `app/settings/page.tsx` (and 11 more in `app/`) -- Why: Rapid prototyping during MVP phase -- Impact: Can't implement RLS properly, exposes DB structure to client -- Fix approach: Move all queries to server actions in `app/actions/`, add proper RLS policies - -**Manual webhook signature validation:** -- Issue: Copy-pasted Stripe webhook verification code in 3 different endpoints -- Files: `app/api/webhooks/stripe/route.ts`, `app/api/webhooks/checkout/route.ts`, `app/api/webhooks/subscription/route.ts` -- Why: Each webhook added ad-hoc without abstraction -- Impact: Easy to miss verification in new webhooks (security risk) -- Fix approach: Create shared `lib/stripe/validate-webhook.ts` middleware - -## Known Bugs - -**Race condition in subscription updates:** -- Symptoms: User shows as "free" tier for 5-10 seconds after successful payment -- Trigger: Fast navigation after Stripe checkout redirect, before webhook processes -- Files: `app/checkout/success/page.tsx` (redirect handler), `app/api/webhooks/stripe/route.ts` (webhook) -- Workaround: Stripe webhook eventually updates status (self-heals) -- Root cause: Webhook processing slower than user navigation, no optimistic UI update -- Fix: Add polling in `app/checkout/success/page.tsx` after redirect - -**Inconsistent session state after logout:** -- Symptoms: User redirected to /dashboard after logout instead of /login -- Trigger: Logout via button in mobile nav (desktop works fine) -- File: `components/MobileNav.tsx` (line ~45, logout handler) -- Workaround: Manual URL navigation to /login works -- Root cause: Mobile nav component not awaiting supabase.auth.signOut() -- Fix: Add await to logout handler in `components/MobileNav.tsx` - -## Security Considerations - -**Admin role check client-side only:** -- Risk: Admin dashboard pages check isAdmin from Supabase client, no server verification -- Files: `app/admin/page.tsx`, `app/admin/users/page.tsx`, `components/AdminGuard.tsx` -- Current mitigation: None (relying on UI hiding) -- Recommendations: Add middleware to admin routes in `middleware.ts`, verify role server-side - -**Unvalidated file uploads:** -- Risk: Users can upload any file type to avatar bucket (no size/type validation) -- File: `components/AvatarUpload.tsx` (upload handler) -- Current mitigation: Supabase bucket limits to 2MB (configured in dashboard) -- Recommendations: Add file type validation (image/* only) in `lib/storage/validate.ts` - -## Performance Bottlenecks - -**/api/courses endpoint:** -- Problem: Fetching all courses with nested lessons and authors -- File: `app/api/courses/route.ts` -- Measurement: 1.2s p95 response time with 50+ courses -- Cause: N+1 query pattern (separate query per course for lessons) -- Improvement path: Use Prisma include to eager-load lessons in `lib/db/courses.ts`, add Redis caching - -**Dashboard initial load:** -- Problem: Waterfall of 5 serial API calls on mount -- File: `app/dashboard/page.tsx` -- Measurement: 3.5s until interactive on slow 3G -- Cause: Each component fetches own data independently -- Improvement path: Convert to Server Component with single parallel fetch - -## Fragile Areas - -**Authentication middleware chain:** -- File: `middleware.ts` -- Why fragile: 4 different middleware functions run in specific order (auth -> role -> subscription -> logging) -- Common failures: Middleware order change breaks everything, hard to debug -- Safe modification: Add tests before changing order, document dependencies in comments -- Test coverage: No integration tests for middleware chain (only unit tests) - -**Stripe webhook event handling:** -- File: `app/api/webhooks/stripe/route.ts` -- Why fragile: Giant switch statement with 12 event types, shared transaction logic -- Common failures: New event type added without handling, partial DB updates on error -- Safe modification: Extract each event handler to `lib/stripe/handlers/*.ts` -- Test coverage: Only 3 of 12 event types have tests - -## Scaling Limits - -**Supabase Free Tier:** -- Current capacity: 500MB database, 1GB file storage, 2GB bandwidth/month -- Limit: ~5000 users estimated before hitting limits -- Symptoms at limit: 429 rate limit errors, DB writes fail -- Scaling path: Upgrade to Pro ($25/mo) extends to 8GB DB, 100GB storage - -**Server-side render blocking:** -- Current capacity: ~50 concurrent users before slowdown -- Limit: Vercel Hobby plan (10s function timeout, 100GB-hrs/mo) -- Symptoms at limit: 504 gateway timeouts on course pages -- Scaling path: Upgrade to Vercel Pro ($20/mo), add edge caching - -## Dependencies at Risk - -**react-hot-toast:** -- Risk: Unmaintained (last update 18 months ago), React 19 compatibility unknown -- Impact: Toast notifications break, no graceful degradation -- Migration plan: Switch to sonner (actively maintained, similar API) - -## Missing Critical Features - -**Payment failure handling:** -- Problem: No retry mechanism or user notification when subscription payment fails -- Current workaround: Users manually re-enter payment info (if they notice) -- Blocks: Can't retain users with expired cards, no dunning process -- Implementation complexity: Medium (Stripe webhooks + email flow + UI) - -**Course progress tracking:** -- Problem: No persistent state for which lessons completed -- Current workaround: Users manually track progress -- Blocks: Can't show completion percentage, can't recommend next lesson -- Implementation complexity: Low (add completed_lessons junction table) - -## Test Coverage Gaps - -**Payment flow end-to-end:** -- What's not tested: Full Stripe checkout -> webhook -> subscription activation flow -- Risk: Payment processing could break silently (has happened twice) -- Priority: High -- Difficulty to test: Need Stripe test fixtures and webhook simulation setup - -**Error boundary behavior:** -- What's not tested: How app behaves when components throw errors -- Risk: White screen of death for users, no error reporting -- Priority: Medium -- Difficulty to test: Need to intentionally trigger errors in test environment - ---- - -*Concerns audit: 2025-01-20* -*Update as issues are fixed or new ones discovered* -``` - - - -**What belongs in CONCERNS.md:** -- Tech debt with clear impact and fix approach -- Known bugs with reproduction steps -- Security gaps and mitigation recommendations -- Performance bottlenecks with measurements -- Fragile code that breaks easily -- Scaling limits with numbers -- Dependencies that need attention -- Missing features that block workflows -- Test coverage gaps - -**What does NOT belong here:** -- Opinions without evidence ("code is messy") -- Complaints without solutions ("auth sucks") -- Future feature ideas (that's for product planning) -- Normal TODOs (those live in code comments) -- Architectural decisions that are working fine -- Minor code style issues - -**When filling this template:** -- **Always include file paths** - Concerns without locations are not actionable. Use backticks: `src/file.ts` -- Be specific with measurements ("500ms p95" not "slow") -- Include reproduction steps for bugs -- Suggest fix approaches, not just problems -- Focus on actionable items -- Prioritize by risk/impact -- Update as issues get resolved -- Add new concerns as discovered - -**Tone guidelines:** -- Professional, not emotional ("N+1 query pattern" not "terrible queries") -- Solution-oriented ("Fix: add index" not "needs fixing") -- Risk-focused ("Could expose user data" not "security is bad") -- Factual ("3.5s load time" not "really slow") - -**Useful for phase planning when:** -- Deciding what to work on next -- Estimating risk of changes -- Understanding where to be careful -- Prioritizing improvements -- Onboarding new Claude contexts -- Planning refactoring work - -**How this gets populated:** -Explore agents detect these during codebase mapping. Manual additions welcome for human-discovered issues. This is living documentation, not a complaint list. - diff --git a/gsd-core/templates/codebase/conventions.md b/gsd-core/templates/codebase/conventions.md deleted file mode 100644 index 361283bea..000000000 --- a/gsd-core/templates/codebase/conventions.md +++ /dev/null @@ -1,307 +0,0 @@ -# Coding Conventions Template - -Template for `.planning/codebase/CONVENTIONS.md` - captures coding style and patterns. - -**Purpose:** Document how code is written in this codebase. Prescriptive guide for Claude to match existing style. - ---- - -## File Template - -```markdown -# Coding Conventions - -**Analysis Date:** [YYYY-MM-DD] - -## Naming Patterns - -**Files:** -- [Pattern: e.g., "kebab-case for all files"] -- [Test files: e.g., "*.test.ts alongside source"] -- [Components: e.g., "PascalCase.tsx for React components"] - -**Functions:** -- [Pattern: e.g., "camelCase for all functions"] -- [Async: e.g., "no special prefix for async functions"] -- [Handlers: e.g., "handleEventName for event handlers"] - -**Variables:** -- [Pattern: e.g., "camelCase for variables"] -- [Constants: e.g., "UPPER_SNAKE_CASE for constants"] -- [Private: e.g., "_prefix for private members" or "no prefix"] - -**Types:** -- [Interfaces: e.g., "PascalCase, no I prefix"] -- [Types: e.g., "PascalCase for type aliases"] -- [Enums: e.g., "PascalCase for enum name, UPPER_CASE for values"] - -## Code Style - -**Formatting:** -- [Tool: e.g., "Prettier with config in .prettierrc"] -- [Line length: e.g., "100 characters max"] -- [Quotes: e.g., "single quotes for strings"] -- [Semicolons: e.g., "required" or "omitted"] - -**Linting:** -- [Tool: e.g., "ESLint with eslint.config.js"] -- [Rules: e.g., "extends airbnb-base, no console in production"] -- [Run: e.g., "npm run lint"] - -## Import Organization - -**Order:** -1. [e.g., "External packages (react, express, etc.)"] -2. [e.g., "Internal modules (@/lib, @/components)"] -3. [e.g., "Relative imports (., ..)"] -4. [e.g., "Type imports (import type {})"] - -**Grouping:** -- [Blank lines: e.g., "blank line between groups"] -- [Sorting: e.g., "alphabetical within each group"] - -**Path Aliases:** -- [Aliases used: e.g., "@/ for src/, @components/ for src/components/"] - -## Error Handling - -**Patterns:** -- [Strategy: e.g., "throw errors, catch at boundaries"] -- [Custom errors: e.g., "extend Error class, named *Error"] -- [Async: e.g., "use try/catch, no .catch() chains"] - -**Error Types:** -- [When to throw: e.g., "invalid input, missing dependencies"] -- [When to return: e.g., "expected failures return Result"] -- [Logging: e.g., "log error with context before throwing"] - -## Logging - -**Framework:** -- [Tool: e.g., "console.log, pino, winston"] -- [Levels: e.g., "debug, info, warn, error"] - -**Patterns:** -- [Format: e.g., "structured logging with context object"] -- [When: e.g., "log state transitions, external calls"] -- [Where: e.g., "log at service boundaries, not in utils"] - -## Comments - -**When to Comment:** -- [e.g., "explain why, not what"] -- [e.g., "document business logic, algorithms, edge cases"] -- [e.g., "avoid obvious comments like // increment counter"] - -**JSDoc/TSDoc:** -- [Usage: e.g., "required for public APIs, optional for internal"] -- [Format: e.g., "use @param, @returns, @throws tags"] - -**TODO Comments:** -- [Pattern: e.g., "// TODO(username): description"] -- [Tracking: e.g., "link to issue number if available"] - -## Function Design - -**Size:** -- [e.g., "keep under 50 lines, extract helpers"] - -**Parameters:** -- [e.g., "max 3 parameters, use object for more"] -- [e.g., "destructure objects in parameter list"] - -**Return Values:** -- [e.g., "explicit returns, no implicit undefined"] -- [e.g., "return early for guard clauses"] - -## Module Design - -**Exports:** -- [e.g., "named exports preferred, default exports for React components"] -- [e.g., "export from index.ts for public API"] - -**Barrel Files:** -- [e.g., "use index.ts to re-export public API"] -- [e.g., "avoid circular dependencies"] - ---- - -*Convention analysis: [date]* -*Update when patterns change* -``` - - -```markdown -# Coding Conventions - -**Analysis Date:** 2025-01-20 - -## Naming Patterns - -**Files:** -- kebab-case for all files (command-handler.ts, user-service.ts) -- *.test.ts alongside source files -- index.ts for barrel exports - -**Functions:** -- camelCase for all functions -- No special prefix for async functions -- handleEventName for event handlers (handleClick, handleSubmit) - -**Variables:** -- camelCase for variables -- UPPER_SNAKE_CASE for constants (MAX_RETRIES, API_BASE_URL) -- No underscore prefix (no private marker in TS) - -**Types:** -- PascalCase for interfaces, no I prefix (User, not IUser) -- PascalCase for type aliases (UserConfig, ResponseData) -- PascalCase for enum names, UPPER_CASE for values (Status.PENDING) - -## Code Style - -**Formatting:** -- Prettier with .prettierrc -- 100 character line length -- Single quotes for strings -- Semicolons required -- 2 space indentation - -**Linting:** -- ESLint with eslint.config.js -- Extends @typescript-eslint/recommended -- No console.log in production code (use logger) -- Run: npm run lint - -## Import Organization - -**Order:** -1. External packages (react, express, commander) -2. Internal modules (@/lib, @/services) -3. Relative imports (./utils, ../types) -4. Type imports (import type { User }) - -**Grouping:** -- Blank line between groups -- Alphabetical within each group -- Type imports last within each group - -**Path Aliases:** -- @/ maps to src/ -- No other aliases defined - -## Error Handling - -**Patterns:** -- Throw errors, catch at boundaries (route handlers, main functions) -- Extend Error class for custom errors (ValidationError, NotFoundError) -- Async functions use try/catch, no .catch() chains - -**Error Types:** -- Throw on invalid input, missing dependencies, invariant violations -- Log error with context before throwing: logger.error({ err, userId }, 'Failed to process') -- Include cause in error message: new Error('Failed to X', { cause: originalError }) - -## Logging - -**Framework:** -- pino logger instance exported from lib/logger.ts -- Levels: debug, info, warn, error (no trace) - -**Patterns:** -- Structured logging with context: logger.info({ userId, action }, 'User action') -- Log at service boundaries, not in utility functions -- Log state transitions, external API calls, errors -- No console.log in committed code - -## Comments - -**When to Comment:** -- Explain why, not what: // Retry 3 times because API has transient failures -- Document business rules: // Users must verify email within 24 hours -- Explain non-obvious algorithms or workarounds -- Avoid obvious comments: // set count to 0 - -**JSDoc/TSDoc:** -- Required for public API functions -- Optional for internal functions if signature is self-explanatory -- Use @param, @returns, @throws tags - -**TODO Comments:** -- Format: // TODO: description (no username, using git blame) -- Link to issue if exists: // TODO: Fix race condition (issue #123) - -## Function Design - -**Size:** -- Keep under 50 lines -- Extract helpers for complex logic -- One level of abstraction per function - -**Parameters:** -- Max 3 parameters -- Use options object for 4+ parameters: function create(options: CreateOptions) -- Destructure in parameter list: function process({ id, name }: ProcessParams) - -**Return Values:** -- Explicit return statements -- Return early for guard clauses -- Use Result type for expected failures - -## Module Design - -**Exports:** -- Named exports preferred -- Default exports only for React components -- Export public API from index.ts barrel files - -**Barrel Files:** -- index.ts re-exports public API -- Keep internal helpers private (don't export from index) -- Avoid circular dependencies (import from specific files if needed) - ---- - -*Convention analysis: 2025-01-20* -*Update when patterns change* -``` - - - -**What belongs in CONVENTIONS.md:** -- Naming patterns observed in the codebase -- Formatting rules (Prettier config, linting rules) -- Import organization patterns -- Error handling strategy -- Logging approach -- Comment conventions -- Function and module design patterns - -**What does NOT belong here:** -- Architecture decisions (that's ARCHITECTURE.md) -- Technology choices (that's STACK.md) -- Test patterns (that's TESTING.md) -- File organization (that's STRUCTURE.md) - -**When filling this template:** -- Check .prettierrc, .eslintrc, or similar config files -- Examine 5-10 representative source files for patterns -- Look for consistency: if 80%+ follows a pattern, document it -- Be prescriptive: "Use X" not "Sometimes Y is used" -- Note deviations: "Legacy code uses Y, new code should use X" -- Keep under ~150 lines total - -**Useful for phase planning when:** -- Writing new code (match existing style) -- Adding features (follow naming patterns) -- Refactoring (apply consistent conventions) -- Code review (check against documented patterns) -- Onboarding (understand style expectations) - -**Analysis approach:** -- Scan src/ directory for file naming patterns -- Check package.json scripts for lint/format commands -- Read 5-10 files to identify function naming, error handling -- Look for config files (.prettierrc, eslint.config.js) -- Note patterns in imports, comments, function signatures - diff --git a/gsd-core/templates/codebase/integrations.md b/gsd-core/templates/codebase/integrations.md deleted file mode 100644 index 9f8a10034..000000000 --- a/gsd-core/templates/codebase/integrations.md +++ /dev/null @@ -1,280 +0,0 @@ -# External Integrations Template - -Template for `.planning/codebase/INTEGRATIONS.md` - captures external service dependencies. - -**Purpose:** Document what external systems this codebase communicates with. Focused on "what lives outside our code that we depend on." - ---- - -## File Template - -```markdown -# External Integrations - -**Analysis Date:** [YYYY-MM-DD] - -## APIs & External Services - -**Payment Processing:** -- [Service] - [What it's used for: e.g., "subscription billing, one-time payments"] - - SDK/Client: [e.g., "stripe npm package v14.x"] - - Auth: [e.g., "API key in STRIPE_SECRET_KEY env var"] - - Endpoints used: [e.g., "checkout sessions, webhooks"] - -**Email/SMS:** -- [Service] - [What it's used for: e.g., "transactional emails"] - - SDK/Client: [e.g., "sendgrid/mail v8.x"] - - Auth: [e.g., "API key in SENDGRID_API_KEY env var"] - - Templates: [e.g., "managed in SendGrid dashboard"] - -**External APIs:** -- [Service] - [What it's used for] - - Integration method: [e.g., "REST API via fetch", "GraphQL client"] - - Auth: [e.g., "OAuth2 token in AUTH_TOKEN env var"] - - Rate limits: [if applicable] - -## Data Storage - -**Databases:** -- [Type/Provider] - [e.g., "PostgreSQL on Supabase"] - - Connection: [e.g., "via DATABASE_URL env var"] - - Client: [e.g., "Prisma ORM v5.x"] - - Migrations: [e.g., "prisma migrate in migrations/"] - -**File Storage:** -- [Service] - [e.g., "AWS S3 for user uploads"] - - SDK/Client: [e.g., "@aws-sdk/client-s3"] - - Auth: [e.g., "IAM credentials in AWS_* env vars"] - - Buckets: [e.g., "prod-uploads, dev-uploads"] - -**Caching:** -- [Service] - [e.g., "Redis for session storage"] - - Connection: [e.g., "REDIS_URL env var"] - - Client: [e.g., "ioredis v5.x"] - -## Authentication & Identity - -**Auth Provider:** -- [Service] - [e.g., "Supabase Auth", "Auth0", "custom JWT"] - - Implementation: [e.g., "Supabase client SDK"] - - Token storage: [e.g., "httpOnly cookies", "localStorage"] - - Session management: [e.g., "JWT refresh tokens"] - -**OAuth Integrations:** -- [Provider] - [e.g., "Google OAuth for sign-in"] - - Credentials: [e.g., "GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET"] - - Scopes: [e.g., "email, profile"] - -## Monitoring & Observability - -**Error Tracking:** -- [Service] - [e.g., "Sentry"] - - DSN: [e.g., "SENTRY_DSN env var"] - - Release tracking: [e.g., "via SENTRY_RELEASE"] - -**Analytics:** -- [Service] - [e.g., "Mixpanel for product analytics"] - - Token: [e.g., "MIXPANEL_TOKEN env var"] - - Events tracked: [e.g., "user actions, page views"] - -**Logs:** -- [Service] - [e.g., "CloudWatch", "Datadog", "none (stdout only)"] - - Integration: [e.g., "AWS Lambda built-in"] - -## CI/CD & Deployment - -**Hosting:** -- [Platform] - [e.g., "Vercel", "AWS Lambda", "Docker on ECS"] - - Deployment: [e.g., "automatic on main branch push"] - - Environment vars: [e.g., "configured in Vercel dashboard"] - -**CI Pipeline:** -- [Service] - [e.g., "GitHub Actions"] - - Workflows: [e.g., "test.yml, deploy.yml"] - - Secrets: [e.g., "stored in GitHub repo secrets"] - -## Environment Configuration - -**Development:** -- Required env vars: [List critical vars] -- Secrets location: [e.g., ".env.local (gitignored)", "1Password vault"] -- Mock/stub services: [e.g., "Stripe test mode", "local PostgreSQL"] - -**Staging:** -- Environment-specific differences: [e.g., "uses staging Stripe account"] -- Data: [e.g., "separate staging database"] - -**Production:** -- Secrets management: [e.g., "Vercel environment variables"] -- Failover/redundancy: [e.g., "multi-region DB replication"] - -## Webhooks & Callbacks - -**Incoming:** -- [Service] - [Endpoint: e.g., "/api/webhooks/stripe"] - - Verification: [e.g., "signature validation via stripe.webhooks.constructEvent"] - - Events: [e.g., "payment_intent.succeeded, customer.subscription.updated"] - -**Outgoing:** -- [Service] - [What triggers it] - - Endpoint: [e.g., "external CRM webhook on user signup"] - - Retry logic: [if applicable] - ---- - -*Integration audit: [date]* -*Update when adding/removing external services* -``` - - -```markdown -# External Integrations - -**Analysis Date:** 2025-01-20 - -## APIs & External Services - -**Payment Processing:** -- Stripe - Subscription billing and one-time course payments - - SDK/Client: stripe npm package v14.8 - - Auth: API key in STRIPE_SECRET_KEY env var - - Endpoints used: checkout sessions, customer portal, webhooks - -**Email/SMS:** -- SendGrid - Transactional emails (receipts, password resets) - - SDK/Client: @sendgrid/mail v8.1 - - Auth: API key in SENDGRID_API_KEY env var - - Templates: Managed in SendGrid dashboard (template IDs in code) - -**External APIs:** -- OpenAI API - Course content generation - - Integration method: REST API via openai npm package v4.x - - Auth: Bearer token in OPENAI_API_KEY env var - - Rate limits: 3500 requests/min (tier 3) - -## Data Storage - -**Databases:** -- PostgreSQL on Supabase - Primary data store - - Connection: via DATABASE_URL env var - - Client: Prisma ORM v5.8 - - Migrations: prisma migrate in prisma/migrations/ - -**File Storage:** -- Supabase Storage - User uploads (profile images, course materials) - - SDK/Client: @supabase/supabase-js v2.x - - Auth: Service role key in SUPABASE_SERVICE_ROLE_KEY - - Buckets: avatars (public), course-materials (private) - -**Caching:** -- None currently (all database queries, no Redis) - -## Authentication & Identity - -**Auth Provider:** -- Supabase Auth - Email/password + OAuth - - Implementation: Supabase client SDK with server-side session management - - Token storage: httpOnly cookies via @supabase/ssr - - Session management: JWT refresh tokens handled by Supabase - -**OAuth Integrations:** -- Google OAuth - Social sign-in - - Credentials: GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET (Supabase dashboard) - - Scopes: email, profile - -## Monitoring & Observability - -**Error Tracking:** -- Sentry - Server and client errors - - DSN: SENTRY_DSN env var - - Release tracking: Git commit SHA via SENTRY_RELEASE - -**Analytics:** -- None (planned: Mixpanel) - -**Logs:** -- Vercel logs - stdout/stderr only - - Retention: 7 days on Pro plan - -## CI/CD & Deployment - -**Hosting:** -- Vercel - Next.js app hosting - - Deployment: Automatic on main branch push - - Environment vars: Configured in Vercel dashboard (synced to .env.example) - -**CI Pipeline:** -- GitHub Actions - Tests and type checking - - Workflows: .github/workflows/ci.yml - - Secrets: None needed (public repo tests only) - -## Environment Configuration - -**Development:** -- Required env vars: DATABASE_URL, NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY -- Secrets location: .env.local (gitignored), team shared via 1Password vault -- Mock/stub services: Stripe test mode, Supabase local dev project - -**Staging:** -- Uses separate Supabase staging project -- Stripe test mode -- Same Vercel account, different environment - -**Production:** -- Secrets management: Vercel environment variables -- Database: Supabase production project with daily backups - -## Webhooks & Callbacks - -**Incoming:** -- Stripe - /api/webhooks/stripe - - Verification: Signature validation via stripe.webhooks.constructEvent - - Events: payment_intent.succeeded, customer.subscription.updated, customer.subscription.deleted - -**Outgoing:** -- None - ---- - -*Integration audit: 2025-01-20* -*Update when adding/removing external services* -``` - - - -**What belongs in INTEGRATIONS.md:** -- External services the code communicates with -- Authentication patterns (where secrets live, not the secrets themselves) -- SDKs and client libraries used -- Environment variable names (not values) -- Webhook endpoints and verification methods -- Database connection patterns -- File storage locations -- Monitoring and logging services - -**What does NOT belong here:** -- Actual API keys or secrets (NEVER write these) -- Internal architecture (that's ARCHITECTURE.md) -- Code patterns (that's PATTERNS.md) -- Technology choices (that's STACK.md) -- Performance issues (that's CONCERNS.md) - -**When filling this template:** -- Check .env.example or .env.template for required env vars -- Look for SDK imports (stripe, @sendgrid/mail, etc.) -- Check for webhook handlers in routes/endpoints -- Note where secrets are managed (not the secrets) -- Document environment-specific differences (dev/staging/prod) -- Include auth patterns for each service - -**Useful for phase planning when:** -- Adding new external service integrations -- Debugging authentication issues -- Understanding data flow outside the application -- Setting up new environments -- Auditing third-party dependencies -- Planning for service outages or migrations - -**Security note:** -Document WHERE secrets live (env vars, Vercel dashboard, 1Password), never WHAT the secrets are. - diff --git a/gsd-core/templates/codebase/structure.md b/gsd-core/templates/codebase/structure.md deleted file mode 100644 index c28556a2e..000000000 --- a/gsd-core/templates/codebase/structure.md +++ /dev/null @@ -1,285 +0,0 @@ -# Structure Template - -Template for `.planning/codebase/STRUCTURE.md` - captures physical file organization. - -**Purpose:** Document where things physically live in the codebase. Answers "where do I put X?" - ---- - -## File Template - -```markdown -# Codebase Structure - -**Analysis Date:** [YYYY-MM-DD] - -## Directory Layout - -[ASCII box-drawing tree of top-level directories with purpose - use ├── └── │ characters for tree structure only] - -``` -[project-root]/ -├── [dir]/ # [Purpose] -├── [dir]/ # [Purpose] -├── [dir]/ # [Purpose] -└── [file] # [Purpose] -``` - -## Directory Purposes - -**[Directory Name]:** -- Purpose: [What lives here] -- Contains: [Types of files: e.g., "*.ts source files", "component directories"] -- Key files: [Important files in this directory] -- Subdirectories: [If nested, describe structure] - -**[Directory Name]:** -- Purpose: [What lives here] -- Contains: [Types of files] -- Key files: [Important files] -- Subdirectories: [Structure] - -## Key File Locations - -**Entry Points:** -- [Path]: [Purpose: e.g., "CLI entry point"] -- [Path]: [Purpose: e.g., "Server startup"] - -**Configuration:** -- [Path]: [Purpose: e.g., "TypeScript config"] -- [Path]: [Purpose: e.g., "Build configuration"] -- [Path]: [Purpose: e.g., "Environment variables"] - -**Core Logic:** -- [Path]: [Purpose: e.g., "Business services"] -- [Path]: [Purpose: e.g., "Database models"] -- [Path]: [Purpose: e.g., "API routes"] - -**Testing:** -- [Path]: [Purpose: e.g., "Unit tests"] -- [Path]: [Purpose: e.g., "Test fixtures"] - -**Documentation:** -- [Path]: [Purpose: e.g., "User-facing docs"] -- [Path]: [Purpose: e.g., "Developer guide"] - -## Naming Conventions - -**Files:** -- [Pattern]: [Example: e.g., "kebab-case.ts for modules"] -- [Pattern]: [Example: e.g., "PascalCase.tsx for React components"] -- [Pattern]: [Example: e.g., "*.test.ts for test files"] - -**Directories:** -- [Pattern]: [Example: e.g., "kebab-case for feature directories"] -- [Pattern]: [Example: e.g., "plural names for collections"] - -**Special Patterns:** -- [Pattern]: [Example: e.g., "index.ts for directory exports"] -- [Pattern]: [Example: e.g., "__tests__ for test directories"] - -## Where to Add New Code - -**New Feature:** -- Primary code: [Directory path] -- Tests: [Directory path] -- Config if needed: [Directory path] - -**New Component/Module:** -- Implementation: [Directory path] -- Types: [Directory path] -- Tests: [Directory path] - -**New Route/Command:** -- Definition: [Directory path] -- Handler: [Directory path] -- Tests: [Directory path] - -**Utilities:** -- Shared helpers: [Directory path] -- Type definitions: [Directory path] - -## Special Directories - -[Any directories with special meaning or generation] - -**[Directory]:** -- Purpose: [e.g., "Generated code", "Build output"] -- Source: [e.g., "Auto-generated by X", "Build artifacts"] -- Committed: [Yes/No - in .gitignore?] - ---- - -*Structure analysis: [date]* -*Update when directory structure changes* -``` - - -```markdown -# Codebase Structure - -**Analysis Date:** 2025-01-20 - -## Directory Layout - -``` -gsd-core/ -├── bin/ # Executable entry points -├── commands/ # Slash command definitions -│ └── gsd/ # GSD-specific commands -├── gsd-core/ # Skill resources -│ ├── references/ # Principle documents -│ ├── templates/ # File templates -│ └── workflows/ # Multi-step procedures -├── src/ # Source code (if applicable) -├── tests/ # Test files -├── package.json # Project manifest -└── README.md # User documentation -``` - -## Directory Purposes - -**bin/** -- Purpose: CLI entry points -- Contains: install.js (installer script) -- Key files: install.js - handles npx installation -- Subdirectories: None - -**commands/gsd/** -- Purpose: Slash command definitions for Claude Code -- Contains: *.md files (one per command) -- Key files: new-project.md, plan-phase.md, execute-plan.md -- Subdirectories: None (flat structure) - -**gsd-core/references/** -- Purpose: Core philosophy and guidance documents -- Contains: principles.md, questioning.md, plan-format.md -- Key files: principles.md - system philosophy -- Subdirectories: None - -**gsd-core/templates/** -- Purpose: Document templates for .planning/ files -- Contains: Template definitions with frontmatter -- Key files: project.md, roadmap.md, plan.md, summary.md -- Subdirectories: codebase/ (new - for stack/architecture/structure templates) - -**gsd-core/workflows/** -- Purpose: Reusable multi-step procedures -- Contains: Workflow definitions called by commands -- Key files: execute-plan.md, research-phase.md -- Subdirectories: None - -## Key File Locations - -**Entry Points:** -- `bin/install.js` - Installation script (npx entry) - -**Configuration:** -- `package.json` - Project metadata, dependencies, bin entry -- `.gitignore` - Excluded files - -**Core Logic:** -- `bin/install.js` - All installation logic (file copying, path replacement) - -**Testing:** -- `tests/` - Test files (if present) - -**Documentation:** -- `README.md` - User-facing installation and usage guide -- `CLAUDE.md` - Instructions for Claude Code when working in this repo - -## Naming Conventions - -**Files:** -- kebab-case.md: Markdown documents -- kebab-case.js: JavaScript source files -- UPPERCASE.md: Important project files (README, CLAUDE, CHANGELOG) - -**Directories:** -- kebab-case: All directories -- Plural for collections: templates/, commands/, workflows/ - -**Special Patterns:** -- {command-name}.md: Slash command definition -- *-template.md: Could be used but templates/ directory preferred - -## Where to Add New Code - -**New Slash Command:** -- Primary code: `commands/gsd/{command-name}.md` -- Tests: `tests/commands/{command-name}.test.js` (if testing implemented) -- Documentation: Update `README.md` with new command - -**New Template:** -- Implementation: `gsd-core/templates/{name}.md` -- Documentation: Template is self-documenting (includes guidelines) - -**New Workflow:** -- Implementation: `gsd-core/workflows/{name}.md` -- Usage: Reference from command with `@~/.claude/gsd-core/workflows/{name}.md` - -**New Reference Document:** -- Implementation: `gsd-core/references/{name}.md` -- Usage: Reference from commands/workflows as needed - -**Utilities:** -- No utilities yet (`install.js` is monolithic) -- If extracted: `src/utils/` - -## Special Directories - -**gsd-core/** -- Purpose: Resources installed to ~/.claude/ -- Source: Copied by bin/install.js during installation -- Committed: Yes (source of truth) - -**commands/** -- Purpose: Slash commands installed to ~/.claude/commands/ -- Source: Copied by bin/install.js during installation -- Committed: Yes (source of truth) - ---- - -*Structure analysis: 2025-01-20* -*Update when directory structure changes* -``` - - - -**What belongs in STRUCTURE.md:** -- Directory layout (ASCII box-drawing tree for structure visualization) -- Purpose of each directory -- Key file locations (entry points, configs, core logic) -- Naming conventions -- Where to add new code (by type) -- Special/generated directories - -**What does NOT belong here:** -- Conceptual architecture (that's ARCHITECTURE.md) -- Technology stack (that's STACK.md) -- Code implementation details (defer to code reading) -- Every single file (focus on directories and key files) - -**When filling this template:** -- Use `tree -L 2` or similar to visualize structure -- Identify top-level directories and their purposes -- Note naming patterns by observing existing files -- Locate entry points, configs, and main logic areas -- Keep directory tree concise (max 2-3 levels) - -**Tree format (ASCII box-drawing characters for structure only):** -``` -root/ -├── dir1/ # Purpose -│ ├── subdir/ # Purpose -│ └── file.ts # Purpose -├── dir2/ # Purpose -└── file.ts # Purpose -``` - -**Useful for phase planning when:** -- Adding new features (where should files go?) -- Understanding project organization -- Finding where specific logic lives -- Following existing conventions - diff --git a/gsd-core/templates/codebase/testing.md b/gsd-core/templates/codebase/testing.md deleted file mode 100644 index 95e53902a..000000000 --- a/gsd-core/templates/codebase/testing.md +++ /dev/null @@ -1,480 +0,0 @@ -# Testing Patterns Template - -Template for `.planning/codebase/TESTING.md` - captures test framework and patterns. - -**Purpose:** Document how tests are written and run. Guide for adding tests that match existing patterns. - ---- - -## File Template - -```markdown -# Testing Patterns - -**Analysis Date:** [YYYY-MM-DD] - -## Test Framework - -**Runner:** -- [Framework: e.g., "Jest 29.x", "Vitest 1.x"] -- [Config: e.g., "jest.config.js in project root"] - -**Assertion Library:** -- [Library: e.g., "built-in expect", "chai"] -- [Matchers: e.g., "toBe, toEqual, toThrow"] - -**Run Commands:** -```bash -[e.g., "npm test" or "npm run test"] # Run all tests -[e.g., "npm test -- --watch"] # Watch mode -[e.g., "npm test -- path/to/file.test.ts"] # Single file -[e.g., "npm run test:coverage"] # Coverage report -``` - -## Test File Organization - -**Location:** -- [Pattern: e.g., "*.test.ts alongside source files"] -- [Alternative: e.g., "__tests__/ directory" or "separate tests/ tree"] - -**Naming:** -- [Unit tests: e.g., "module-name.test.ts"] -- [Integration: e.g., "feature-name.integration.test.ts"] -- [E2E: e.g., "user-flow.e2e.test.ts"] - -**Structure:** -``` -[Show actual directory pattern, e.g.: -src/ - lib/ - utils.ts - utils.test.ts - services/ - user-service.ts - user-service.test.ts -] -``` - -## Test Structure - -**Suite Organization:** -```typescript -[Show actual pattern used, e.g.: - -describe('ModuleName', () => { - describe('functionName', () => { - it('should handle success case', () => { - // arrange - // act - // assert - }); - - it('should handle error case', () => { - // test code - }); - }); -}); -] -``` - -**Patterns:** -- [Setup: e.g., "beforeEach for shared setup, avoid beforeAll"] -- [Teardown: e.g., "afterEach to clean up, restore mocks"] -- [Structure: e.g., "arrange/act/assert pattern required"] - -## Mocking - -**Framework:** -- [Tool: e.g., "Jest built-in mocking", "Vitest vi", "Sinon"] -- [Import mocking: e.g., "vi.mock() at top of file"] - -**Patterns:** -```typescript -[Show actual mocking pattern, e.g.: - -// Mock external dependency -vi.mock('./external-service', () => ({ - fetchData: vi.fn() -})); - -// Mock in test -const mockFetch = vi.mocked(fetchData); -mockFetch.mockResolvedValue({ data: 'test' }); -] -``` - -**What to Mock:** -- [e.g., "External APIs, file system, database"] -- [e.g., "Time/dates (use vi.useFakeTimers)"] -- [e.g., "Network calls (use mock fetch)"] - -**What NOT to Mock:** -- [e.g., "Pure functions, utilities"] -- [e.g., "Internal business logic"] - -## Fixtures and Factories - -**Test Data:** -```typescript -[Show pattern for creating test data, e.g.: - -// Factory pattern -function createTestUser(overrides?: Partial): User { - return { - id: 'test-id', - name: 'Test User', - email: 'test@example.com', - ...overrides - }; -} - -// Fixture file -// tests/fixtures/users.ts -export const mockUsers = [/* ... */]; -] -``` - -**Location:** -- [e.g., "tests/fixtures/ for shared fixtures"] -- [e.g., "factory functions in test file or tests/factories/"] - -## Coverage - -**Requirements:** -- [Target: e.g., "80% line coverage", "no specific target"] -- [Enforcement: e.g., "CI blocks <80%", "coverage for awareness only"] - -**Configuration:** -- [Tool: e.g., "built-in coverage via --coverage flag"] -- [Exclusions: e.g., "exclude *.test.ts, config files"] - -**View Coverage:** -```bash -[e.g., "npm run test:coverage"] -[e.g., "open coverage/index.html"] -``` - -## Test Types - -**Unit Tests:** -- [Scope: e.g., "test single function/class in isolation"] -- [Mocking: e.g., "mock all external dependencies"] -- [Speed: e.g., "must run in <1s per test"] - -**Integration Tests:** -- [Scope: e.g., "test multiple modules together"] -- [Mocking: e.g., "mock external services, use real internal modules"] -- [Setup: e.g., "use test database, seed data"] - -**E2E Tests:** -- [Framework: e.g., "Playwright for E2E"] -- [Scope: e.g., "test full user flows"] -- [Location: e.g., "e2e/ directory separate from unit tests"] - -## Common Patterns - -**Async Testing:** -```typescript -[Show pattern, e.g.: - -it('should handle async operation', async () => { - const result = await asyncFunction(); - expect(result).toBe('expected'); -}); -] -``` - -**Error Testing:** -```typescript -[Show pattern, e.g.: - -it('should throw on invalid input', () => { - expect(() => functionCall()).toThrow('error message'); -}); - -// Async error -it('should reject on failure', async () => { - await expect(asyncCall()).rejects.toThrow('error message'); -}); -] -``` - -**Snapshot Testing:** -- [Usage: e.g., "for React components only" or "not used"] -- [Location: e.g., "__snapshots__/ directory"] - ---- - -*Testing analysis: [date]* -*Update when test patterns change* -``` - - -```markdown -# Testing Patterns - -**Analysis Date:** 2025-01-20 - -## Test Framework - -**Runner:** -- Vitest 1.0.4 -- Config: vitest.config.ts in project root - -**Assertion Library:** -- Vitest built-in expect -- Matchers: toBe, toEqual, toThrow, toMatchObject - -**Run Commands:** -```bash -npm test # Run all tests -npm test -- --watch # Watch mode -npm test -- path/to/file.test.ts # Single file -npm run test:coverage # Coverage report -``` - -## Test File Organization - -**Location:** -- *.test.ts alongside source files -- No separate tests/ directory - -**Naming:** -- unit-name.test.ts for all tests -- No distinction between unit/integration in filename - -**Structure:** -``` -src/ - lib/ - parser.ts - parser.test.ts - services/ - install-service.ts - install-service.test.ts - bin/ - install.ts - (no test - integration tested via CLI) -``` - -## Test Structure - -**Suite Organization:** -```typescript -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; - -describe('ModuleName', () => { - describe('functionName', () => { - beforeEach(() => { - // reset state - }); - - it('should handle valid input', () => { - // arrange - const input = createTestInput(); - - // act - const result = functionName(input); - - // assert - expect(result).toEqual(expectedOutput); - }); - - it('should throw on invalid input', () => { - expect(() => functionName(null)).toThrow('Invalid input'); - }); - }); -}); -``` - -**Patterns:** -- Use beforeEach for per-test setup, avoid beforeAll -- Use afterEach to restore mocks: vi.restoreAllMocks() -- Explicit arrange/act/assert comments in complex tests -- One assertion focus per test (but multiple expects OK) - -## Mocking - -**Framework:** -- Vitest built-in mocking (vi) -- Module mocking via vi.mock() at top of test file - -**Patterns:** -```typescript -import { vi } from 'vitest'; -import { externalFunction } from './external'; - -// Mock module -vi.mock('./external', () => ({ - externalFunction: vi.fn() -})); - -describe('test suite', () => { - it('mocks function', () => { - const mockFn = vi.mocked(externalFunction); - mockFn.mockReturnValue('mocked result'); - - // test code using mocked function - - expect(mockFn).toHaveBeenCalledWith('expected arg'); - }); -}); -``` - -**What to Mock:** -- File system operations (fs-extra) -- Child process execution (child_process.exec) -- External API calls -- Environment variables (process.env) - -**What NOT to Mock:** -- Internal pure functions -- Simple utilities (string manipulation, array helpers) -- TypeScript types - -## Fixtures and Factories - -**Test Data:** -```typescript -// Factory functions in test file -function createTestConfig(overrides?: Partial): Config { - return { - targetDir: '/tmp/test', - global: false, - ...overrides - }; -} - -// Shared fixtures in tests/fixtures/ -// tests/fixtures/sample-command.md -export const sampleCommand = `--- -description: Test command ---- -Content here`; -``` - -**Location:** -- Factory functions: define in test file near usage -- Shared fixtures: tests/fixtures/ (for multi-file test data) -- Mock data: inline in test when simple, factory when complex - -## Coverage - -**Requirements:** -- No enforced coverage target -- Coverage tracked for awareness -- Focus on critical paths (parsers, service logic) - -**Configuration:** -- Vitest coverage via c8 (built-in) -- Excludes: *.test.ts, bin/install.ts, config files - -**View Coverage:** -```bash -npm run test:coverage -open coverage/index.html -``` - -## Test Types - -**Unit Tests:** -- Test single function in isolation -- Mock all external dependencies (fs, child_process) -- Fast: each test <100ms -- Examples: parser.test.ts, validator.test.ts - -**Integration Tests:** -- Test multiple modules together -- Mock only external boundaries (file system, process) -- Examples: install-service.test.ts (tests service + parser) - -**E2E Tests:** -- Not currently used -- CLI integration tested manually - -## Common Patterns - -**Async Testing:** -```typescript -it('should handle async operation', async () => { - const result = await asyncFunction(); - expect(result).toBe('expected'); -}); -``` - -**Error Testing:** -```typescript -it('should throw on invalid input', () => { - expect(() => parse(null)).toThrow('Cannot parse null'); -}); - -// Async error -it('should reject on file not found', async () => { - await expect(readConfig('invalid.txt')).rejects.toThrow('ENOENT'); -}); -``` - -**File System Mocking:** -```typescript -import { vi } from 'vitest'; -import * as fs from 'fs-extra'; - -vi.mock('fs-extra'); - -it('mocks file system', () => { - vi.mocked(fs.readFile).mockResolvedValue('file content'); - // test code -}); -``` - -**Snapshot Testing:** -- Not used in this codebase -- Prefer explicit assertions for clarity - ---- - -*Testing analysis: 2025-01-20* -*Update when test patterns change* -``` - - - -**What belongs in TESTING.md:** -- Test framework and runner configuration -- Test file location and naming patterns -- Test structure (describe/it, beforeEach patterns) -- Mocking approach and examples -- Fixture/factory patterns -- Coverage requirements -- How to run tests (commands) -- Common testing patterns in actual code - -**What does NOT belong here:** -- Specific test cases (defer to actual test files) -- Technology choices (that's STACK.md) -- CI/CD setup (that's deployment docs) - -**When filling this template:** -- Check package.json scripts for test commands -- Find test config file (jest.config.js, vitest.config.ts) -- Read 3-5 existing test files to identify patterns -- Look for test utilities in tests/ or test-utils/ -- Check for coverage configuration -- Document actual patterns used, not ideal patterns - -**Useful for phase planning when:** -- Adding new features (write matching tests) -- Refactoring (maintain test patterns) -- Fixing bugs (add regression tests) -- Understanding verification approach -- Setting up test infrastructure - -**Analysis approach:** -- Check package.json for test framework and scripts -- Read test config file for coverage, setup -- Examine test file organization (collocated vs separate) -- Review 5 test files for patterns (mocking, structure, assertions) -- Look for test utilities, fixtures, factories -- Note any test types (unit, integration, e2e) -- Document commands for running tests - diff --git a/gsd-core/templates/debug-subagent-prompt.md b/gsd-core/templates/debug-subagent-prompt.md deleted file mode 100644 index c90c7ce45..000000000 --- a/gsd-core/templates/debug-subagent-prompt.md +++ /dev/null @@ -1,91 +0,0 @@ -# Debug Subagent Prompt Template - -Template for spawning gsd-debugger agent. The agent contains all debugging expertise - this template provides problem context only. - ---- - -## Template - -```markdown - -Investigate issue: {issue_id} - -**Summary:** {issue_summary} - - - -expected: {expected} -actual: {actual} -errors: {errors} -reproduction: {reproduction} -timeline: {timeline} - - - -symptoms_prefilled: {true_or_false} -goal: {find_root_cause_only | find_and_fix} - - - -Create: .planning/debug/{slug}.md - -``` - ---- - -## Placeholders - -| Placeholder | Source | Example | -|-------------|--------|---------| -| `{issue_id}` | Orchestrator-assigned | `auth-screen-dark` | -| `{issue_summary}` | User description | `Auth screen is too dark` | -| `{expected}` | From symptoms | `See logo clearly` | -| `{actual}` | From symptoms | `Screen is dark` | -| `{errors}` | From symptoms | `None in console` | -| `{reproduction}` | From symptoms | `Open /auth page` | -| `{timeline}` | From symptoms | `After recent deploy` | -| `{goal}` | Orchestrator sets | `find_and_fix` | -| `{slug}` | Generated | `auth-screen-dark` | - ---- - -## Usage - -**From /gsd:debug:** -```python -Task( - prompt=filled_template, - subagent_type="gsd-debugger", - description="Debug {slug}" -) -``` - -**From diagnose-issues (UAT):** -```python -Task(prompt=template, subagent_type="gsd-debugger", description="Debug UAT-001") -``` - ---- - -## Continuation - -For checkpoints, spawn fresh agent with: - -```markdown - -Continue debugging {slug}. Evidence is in the debug file. - - - -Debug file: @.planning/debug/{slug}.md - - - -**Type:** {checkpoint_type} -**Response:** {user_response} - - - -goal: {goal} - -``` diff --git a/gsd-core/templates/discovery.md b/gsd-core/templates/discovery.md deleted file mode 100644 index 0c707e401..000000000 --- a/gsd-core/templates/discovery.md +++ /dev/null @@ -1,146 +0,0 @@ -# Discovery Template - -Template for `.planning/phases/XX-name/DISCOVERY.md` - shallow research for library/option decisions. - -**Purpose:** Answer "which library/option should we use" questions during mandatory discovery in plan-phase. - -For deep ecosystem research ("how do experts build this"), use `/gsd:plan-phase --research-phase` which produces RESEARCH.md. - ---- - -## File Template - -```markdown ---- -phase: XX-name -type: discovery -topic: [discovery-topic] ---- - - -Before beginning discovery, verify today's date: -!`date +%Y-%m-%d` - -Use this date when searching for "current" or "latest" information. -Example: If today is 2025-11-22, search for "2025" not "2024". - - - -Discover [topic] to inform [phase name] implementation. - -Purpose: [What decision/implementation this enables] -Scope: [Boundaries] -Output: DISCOVERY.md with recommendation - - - - -- [Question to answer] -- [Area to investigate] -- [Specific comparison if needed] - - - -- [Out of scope for this discovery] -- [Defer to implementation phase] - - - - - -**Source Priority:** -1. **Context7 MCP** - For library/framework documentation (current, authoritative) -2. **Official Docs** - For platform-specific or non-indexed libraries -3. **WebSearch** - For comparisons, trends, community patterns (verify all findings) - -**Quality Checklist:** -Before completing discovery, verify: -- [ ] All claims have authoritative sources (Context7 or official docs) -- [ ] Negative claims ("X is not possible") verified with official documentation -- [ ] API syntax/configuration from Context7 or official docs (never WebSearch alone) -- [ ] WebSearch findings cross-checked with authoritative sources -- [ ] Recent updates/changelogs checked for breaking changes -- [ ] Alternative approaches considered (not just first solution found) - -**Confidence Levels:** -- HIGH: Context7 or official docs confirm -- MEDIUM: WebSearch + Context7/official docs confirm -- LOW: WebSearch only or training knowledge only (mark for validation) - - - - - -Create `.planning/phases/XX-name/DISCOVERY.md`: - -```markdown -# [Topic] Discovery - -## Summary -[2-3 paragraph executive summary - what was researched, what was found, what's recommended] - -## Primary Recommendation -[What to do and why - be specific and actionable] - -## Alternatives Considered -[What else was evaluated and why not chosen] - -## Key Findings - -### [Category 1] -- [Finding with source URL and relevance to our case] - -### [Category 2] -- [Finding with source URL and relevance] - -## Code Examples -[Relevant implementation patterns, if applicable] - -## Metadata - - - -[Why this confidence level - based on source quality and verification] - - - -- [Primary authoritative sources used] - - - -[What couldn't be determined or needs validation during implementation] - - - -[If confidence is LOW or MEDIUM, list specific things to verify during implementation] - - -``` - - - -- All scope questions answered with authoritative sources -- Quality checklist items completed -- Clear primary recommendation -- Low-confidence findings marked with validation checkpoints -- Ready to inform PLAN.md creation - - - -**When to use discovery:** -- Technology choice unclear (library A vs B) -- Best practices needed for unfamiliar integration -- API/library investigation required -- Single decision pending - -**When NOT to use:** -- Established patterns (CRUD, auth with known library) -- Implementation details (defer to execution) -- Questions answerable from existing project context - -**When to use RESEARCH.md instead:** -- Niche/complex domains (3D, games, audio, shaders) -- Need ecosystem knowledge, not just library choice -- "How do experts build this" questions -- Use `/gsd:plan-phase --research-phase` for these - diff --git a/gsd-core/templates/summary.compact.md b/gsd-core/templates/summary.compact.md new file mode 100644 index 000000000..469c92b4d --- /dev/null +++ b/gsd-core/templates/summary.compact.md @@ -0,0 +1,212 @@ +# Summary Template + +Template for `.planning/phases/XX-name/{phase}-{plan}-SUMMARY.md` - phase completion documentation. + +--- + +## File Template + +```markdown +--- +phase: XX-name +plan: YY +subsystem: [primary category: auth, payments, ui, api, database, infra, testing, etc.] +tags: [searchable tech: jwt, stripe, react, postgres, prisma] + +# Dependency graph +requires: + - phase: [prior phase this depends on] + provides: [what that phase built that this uses] +provides: + - [bullet list of what this phase built/delivered] +affects: [list of phase names or keywords that will need this context] + +# Actuals (#2632) — pairs with the plan's `estimate` to calibrate future estimates. +# Same estimateTokens scale (chars/4 over the realized diff), never a harness token count. +actuals: + tokens: [chars/4 over files actually changed] + tasks: [tasks completed] + commits: [commits made] + +# Tech tracking +tech-stack: + added: [libraries/tools added in this phase] + patterns: [architectural/code patterns established] + +key-files: + created: [important files created] + modified: [important files modified] + +key-decisions: + - "Decision 1" + - "Decision 2" + +patterns-established: + - "Pattern 1: description" + - "Pattern 2: description" + +requirements-completed: [] # REQUIRED — Copy ALL requirement IDs from this plan's `requirements` frontmatter field. + +# Coverage metadata (#1602) — one entry per shipped deliverable. Drives DETERMINISTIC UAT routing in verify-work. +# OMIT this whole block for legacy/prose-only SUMMARYs — verify-work then falls back to the ## Accomplishments bullets +# (byte-identical behavior for un-migrated phases). See below for the contract. +coverage: + - id: D1 + description: "[deliverable in human-readable form — what would have been a prose ## Accomplishments bullet]" + requirement: "[REQ-ID from this plan's `requirements`, or omit if none]" + verification: + - kind: unit # unit | integration | e2e | automated_ui | manual_procedural | other + ref: "[tests/path.test.ts#test name | playwright:shot.png | command invocation]" + status: pass # pass | fail | unknown — from the latest run + human_judgment: false # REQUIRED boolean. false => may auto-pass IF every verification status is `pass`. + - id: D2 + description: "[a deliverable that needs a human to sign off]" + verification: [] + human_judgment: true + rationale: "[REQUIRED when human_judgment: true — why automation is insufficient]" + +# Metrics +duration: Xmin +completed: YYYY-MM-DD +status: complete +--- + +# Phase [X]: [Name] Summary + +**[Substantive one-liner describing outcome - NOT "phase complete" or "implementation finished"]** + +## Performance + +- **Duration:** [time] (e.g., 23 min, 1h 15m) +- **Started:** [ISO timestamp] +- **Completed:** [ISO timestamp] +- **Tasks:** [count completed] +- **Files modified:** [count] + +## Accomplishments +- [Most important outcome] +- [Second key accomplishment] +- [Third if applicable] + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: [task name]** - `abc123f` (feat/fix/test/refactor) +2. **Task 2: [task name]** - `def456g` (feat/fix/test/refactor) +3. **Task 3: [task name]** - `hij789k` (feat/fix/test/refactor) + +**Plan metadata:** `lmn012o` (docs: complete plan) + +_Note: TDD tasks may have multiple commits (test → feat → refactor)_ + +## Files Created/Modified +- `path/to/file.ts` - What it does +- `path/to/another.ts` - What it does + +## Decisions Made +[Key decisions with brief rationale, or "None - followed plan as specified"] + +## Deviations from Plan + +[If no deviations: "None - plan executed exactly as written"] + +[If deviations occurred:] + +### Auto-fixed Issues + +**1. [Rule X - Category] Brief description** +- **Found during:** Task [N] ([task name]) +- **Issue:** [What was wrong] +- **Fix:** [What was done] +- **Files modified:** [file paths] +- **Verification:** [How it was verified] +- **Committed in:** [hash] (part of task commit) + +[... repeat for each auto-fix ...] + +--- + +**Total deviations:** [N] auto-fixed ([breakdown by rule]) +**Impact on plan:** [Brief assessment - e.g., "All auto-fixes necessary for correctness/security. No scope creep."] + +## Issues Encountered +[Problems and how they were resolved, or "None"] + +[Note: "Deviations from Plan" documents unplanned work that was handled automatically via deviation rules. "Issues Encountered" documents problems during planned work that required problem-solving.] + +## User Setup Required + +[If USER-SETUP.md was generated:] +**External services require manual configuration.** See [{phase}-USER-SETUP.md](./{phase}-USER-SETUP.md) for: +- Environment variables to add +- Dashboard configuration steps +- Verification commands + +[If no USER-SETUP.md:] +None - no external service configuration required. + +## Next Phase Readiness +[What's ready for next phase] +[Any blockers or concerns] + +--- +*Phase: XX-name* +*Completed: [date]* +``` + + +**Purpose:** Enable automatic context assembly via dependency graph. Frontmatter makes summary metadata machine-readable so plan-phase can scan all summaries quickly and select relevant ones based on dependencies (`requires`/`provides`/`affects` create the explicit links; transitive closure follows from them). + +**Subsystem/Tags:** Primary categorization + searchable technical keywords, for detecting related phases and tech-stack awareness. **Key-files:** important files for @context references in PLAN.md. **Patterns:** established conventions future phases should maintain. + +**Population:** Frontmatter is populated during summary creation in execute-plan.md. See `` for field-by-field guidance. + +**Status (#2830):** `status: complete` is the default — the plan finished. Use `status: halted` instead when the plan reached a designed stop (a gate failure, a spike concluding without expanding into the full build, or any other intentional non-completion) and intentionally left tasks unfinished. `halted` is machine-read: any plan whose `depends_on` (directly or transitively) names a halted plan is reported as blocked, not offered to the executor, until the halt is resolved and re-summarized as `complete`. + + + +**Purpose (#1602):** The `coverage:` block is a per-deliverable Requirements Traceability Matrix. It lets `verify-work`'s `extract_tests` step route deliverables DETERMINISTICALLY — auto-passing those proven by passing tests and reserving human UAT for genuine judgment — instead of re-deriving coverage from prose. Consumed via `gsd-tools uat classify-coverage --summary `. + +**Field semantics:** + +| Field | Purpose | +|---|---| +| `id` | Stable identifier (`D1`, `D2`…) for cross-referencing from UAT.md and audit reports. Must be unique within the SUMMARY. | +| `description` | The deliverable in human-readable form — what would have been a prose bullet. | +| `requirement` | Links back to a REQUIREMENTS.md REQ-ID (joins `requirements-completed`). Optional. | +| `verification[].kind` | Enum: `unit \| integration \| e2e \| automated_ui \| manual_procedural \| other`. | +| `verification[].ref` | Test path + descriptor (`file#test name`), Playwright screenshot ref, or command invocation. Required per entry. | +| `verification[].status` | `pass \| fail \| unknown` — populated from the latest test run. | +| `human_judgment` | Explicit boolean; REQUIRED. `true` always routes to a human. | +| `rationale` | REQUIRED when `human_judgment: true`. The audit trail for why automation is insufficient. | + +**Deterministic contract (what the classifier does):** +- A deliverable auto-passes (no human prompt) **only** when `human_judgment: false` AND `verification` is non-empty AND every `verification[].status` is `pass`. This is the narrow, fully-proven case. +- **Everything else is presented to a human** — `human_judgment: true`, an empty `verification:`, any non-`pass`/`unknown` status, or any schema error. A false-negative is a redundant prompt (the status quo); a false-positive ships a bug UAT existed to catch. +- **Fail-safe default:** if you cannot determine coverage for a deliverable, you MUST set `human_judgment: true` with `rationale: "Coverage not determined at authoring time — verifier must classify"`. Never leave a deliverable's `human_judgment` empty, and never set it `false` just to skip the prompt — auto-pass additionally requires a passing `verification` entry, so the flag alone cannot skip the human. +- `coverage: []` means "no deliverables to classify" (the single-confirmation path). OMITTING the block entirely means "legacy" — `verify-work` falls back to prose `## Accomplishments` extraction unchanged. + + + +The one-liner MUST be substantive: + +**Good:** "JWT auth with refresh rotation using jose library" · "Prisma schema with User, Session, and Product models" · "Dashboard with real-time metrics via Server-Sent Events" + +**Bad:** "Phase complete" · "Authentication implemented" · "Foundation finished" · "All tasks done" + +The one-liner should tell someone what actually shipped. + + + +**Frontmatter:** MANDATORY - complete all fields. Enables automatic context assembly for future planning. + +**One-liner:** Must be substantive. "JWT auth with refresh rotation using jose library" not "Authentication implemented". + +**Decisions section:** +- Key decisions made during execution with rationale +- Extracted to STATE.md accumulated context +- Use "None - followed plan as specified" if no deviations + +**After creation:** STATE.md updated with position, decisions, issues. + diff --git a/gsd-core/templates/user-setup.compact.md b/gsd-core/templates/user-setup.compact.md new file mode 100644 index 000000000..63caa77ca --- /dev/null +++ b/gsd-core/templates/user-setup.compact.md @@ -0,0 +1,199 @@ +# User Setup Template + +Template for `.planning/phases/XX-name/{phase}-USER-SETUP.md` - human-required configuration that Claude cannot automate. + +**Purpose:** Document setup tasks that literally require human action - account creation, dashboard configuration, secret retrieval. Claude automates everything possible; this file captures only what remains. + +--- + +## File Template + +```markdown +# Phase {X}: User Setup Required + +**Generated:** [YYYY-MM-DD] +**Phase:** {phase-name} +**Status:** Incomplete + +Complete these items for the integration to function. Claude automated everything possible; these items require human access to external dashboards/accounts. + +## Environment Variables + +| Status | Variable | Source | Add to | +|--------|----------|--------|--------| +| [ ] | `ENV_VAR_NAME` | [Service Dashboard → Path → To → Value] | `.env.local` | +| [ ] | `ANOTHER_VAR` | [Service Dashboard → Path → To → Value] | `.env.local` | + +## Account Setup + +[Only if new account creation is required] + +- [ ] **Create [Service] account** + - URL: [signup URL] + - Skip if: Already have account + +## Dashboard Configuration + +[Only if dashboard configuration is required] + +- [ ] **[Configuration task]** + - Location: [Service Dashboard → Path → To → Setting] + - Set to: [Required value or configuration] + - Notes: [Any important details] + +## Verification + +After completing setup, verify with: + +```bash +# [Verification commands] +``` + +Expected results: +- [What success looks like] + +--- + +**Once all items complete:** Mark status as "Complete" at top of file. +``` + +--- + +## When to Generate + +Generate `{phase}-USER-SETUP.md` when plan frontmatter contains `user_setup` field. + +**Trigger:** `user_setup` exists in PLAN.md frontmatter and has items. + +**Location:** Same directory as PLAN.md and SUMMARY.md. + +**Timing:** Generated during execute-plan.md after tasks complete, before SUMMARY.md creation. + +--- + +## Frontmatter Schema + +In PLAN.md, `user_setup` declares human-required configuration: + +```yaml +user_setup: + - service: stripe + why: "Payment processing requires API keys" + env_vars: + - name: STRIPE_SECRET_KEY + source: "Stripe Dashboard → Developers → API keys → Secret key" + - name: STRIPE_WEBHOOK_SECRET + source: "Stripe Dashboard → Developers → Webhooks → Signing secret" + dashboard_config: + - task: "Create webhook endpoint" + location: "Stripe Dashboard → Developers → Webhooks → Add endpoint" + details: "URL: https://[your-domain]/api/webhooks/stripe, Events: checkout.session.completed, customer.subscription.*" + local_dev: + - "Run: stripe listen --forward-to localhost:3000/api/webhooks/stripe" + - "Use the webhook secret from CLI output for local testing" +``` + +--- + +## The Automation-First Rule + +**USER-SETUP.md contains ONLY what Claude literally cannot do.** + +| Claude CAN Do (not in USER-SETUP) | Claude CANNOT Do (→ USER-SETUP) | +|-----------------------------------|--------------------------------| +| `npm install stripe` | Create Stripe account | +| Write webhook handler code | Get API keys from dashboard | +| Create `.env.local` file structure | Copy actual secret values | +| Run `stripe listen` | Authenticate Stripe CLI (browser OAuth) | +| Configure package.json | Access external service dashboards | +| Write any code | Retrieve secrets from third-party systems | + +**The test:** "Does this require a human in a browser, accessing an account Claude doesn't have credentials for?" +- Yes → USER-SETUP.md +- No → Claude does it automatically + +--- + +## Service-Specific Example + + +```markdown +# Phase 10: User Setup Required + +**Generated:** 2025-01-14 +**Phase:** 10-monetization +**Status:** Incomplete + +Complete these items for Stripe integration to function. + +## Environment Variables + +| Status | Variable | Source | Add to | +|--------|----------|--------|--------| +| [ ] | `STRIPE_SECRET_KEY` | Stripe Dashboard → Developers → API keys → Secret key | `.env.local` | +| [ ] | `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` | Stripe Dashboard → Developers → API keys → Publishable key | `.env.local` | +| [ ] | `STRIPE_WEBHOOK_SECRET` | Stripe Dashboard → Developers → Webhooks → [endpoint] → Signing secret | `.env.local` | + +## Account Setup + +- [ ] **Create Stripe account** (if needed) + - URL: https://dashboard.stripe.com/register + - Skip if: Already have Stripe account + +## Dashboard Configuration + +- [ ] **Create webhook endpoint** + - Location: Stripe Dashboard → Developers → Webhooks → Add endpoint + - Endpoint URL: `https://[your-domain]/api/webhooks/stripe` + - Events to send: + - `checkout.session.completed` + - `customer.subscription.created` + - `customer.subscription.updated` + - `customer.subscription.deleted` + +- [ ] **Create products and prices** (if using subscription tiers) + - Location: Stripe Dashboard → Products → Add product + - Create each subscription tier + - Copy Price IDs to: + - `STRIPE_STARTER_PRICE_ID` + - `STRIPE_PRO_PRICE_ID` + +## Local Development + +For local webhook testing: +```bash +stripe listen --forward-to localhost:3000/api/webhooks/stripe +``` +Use the webhook signing secret from CLI output (starts with `whsec_`). + +## Verification + +After completing setup: + +```bash +# Verify build passes +npm run build + +# Test webhook endpoint (should return 400 bad signature, not 500 crash) +curl -X POST http://localhost:3000/api/webhooks/stripe \ + -H "Content-Type: application/json" \ + -d '{}' +``` + +Expected: Build passes, webhook returns 400 (signature validation working). + +--- + +**Once all items complete:** Mark status as "Complete" at top of file. +``` + + +--- + +## Guidelines + +**Never include:** Actual secret values. Steps Claude can automate (package installs, code changes). + +**Naming:** `{phase}-USER-SETUP.md` matches the phase number pattern. +**Status tracking:** User marks checkboxes and updates status line when complete. +**Searchability:** `grep -r "USER-SETUP" .planning/` finds all phases with user requirements. diff --git a/gsd-core/workflows/docs-update.md b/gsd-core/workflows/docs-update.md index 5b16b2b0b..9d1fc1a27 100644 --- a/gsd-core/workflows/docs-update.md +++ b/gsd-core/workflows/docs-update.md @@ -288,7 +288,7 @@ Mode resolution: | architecture | docs/architecture/overview.md | create | new directory | | getting_started | docs/guides/getting-started.md | update | found, hand-written | | development | docs/guides/development.md | create | matched docs/guides/ | -| testing | docs/guides/testing.md | create | matched docs/guides/ | +| contributing | docs/guides/contributing.md | create | matched docs/guides/ | | configuration | docs/guides/configuration.md | create | matched docs/guides/ | | api | docs/api/reference.md | create | new directory | | deployment | docs/guides/deployment.md | update | found, hand-written | diff --git a/gsd-core/workflows/execute-plan.md b/gsd-core/workflows/execute-plan.md index 2115dc8ae..7c4183662 100644 --- a/gsd-core/workflows/execute-plan.md +++ b/gsd-core/workflows/execute-plan.md @@ -400,7 +400,7 @@ fi grep -A 50 "^user_setup:" .planning/phases/XX-name/{phase}-{plan}-PLAN.md | head -50 ``` -If user_setup exists: create `{phase}-USER-SETUP.md` using template `~/.claude/gsd-core/templates/user-setup.md`. Per service: env vars table, account setup checklist, dashboard config, local dev notes, verification commands. Status "Incomplete". Set `USER_SETUP_CREATED=true`. If empty/missing: skip. +If user_setup exists: create `{phase}-USER-SETUP.md` using the template at `~/.claude/gsd-core/templates/user-setup.md` (or its `~/.claude/gsd-core/templates/user-setup.compact.md` variant — resolve per `~/.claude/gsd-core/references/compact-content-gate.md` §"Streams 1b and 4"). Per service: env vars table, account setup checklist, dashboard config, local dev notes, verification commands. Status "Incomplete". Set `USER_SETUP_CREATED=true`. If empty/missing: skip. @@ -409,7 +409,7 @@ emit narrative output between the Write tool call and the commit tool call. Truncation at this boundary is a known failure mode (see #2070 rescue logic in execute-phase.md step 5.5). -Create `{phase}-{plan}-SUMMARY.md` at `.planning/phases/XX-name/`. Use `~/.claude/gsd-core/templates/summary.md`. +Create `{phase}-{plan}-SUMMARY.md` at `.planning/phases/XX-name/`. Use the template at `~/.claude/gsd-core/templates/summary.md` (or its `~/.claude/gsd-core/templates/summary.compact.md` variant — resolve per `~/.claude/gsd-core/references/compact-content-gate.md` §"Streams 1b and 4"). **Frontmatter:** phase, plan, subsystem, tags | requires/provides/affects | tech-stack.added/patterns | key-files.created/modified | key-decisions | requirements-completed (**MUST** copy `requirements` array from PLAN.md frontmatter verbatim) | duration ($DURATION), completed ($PLAN_END_TIME date). diff --git a/gsd-core/workflows/help.md b/gsd-core/workflows/help.md index 7902315f4..4cc851d2d 100644 --- a/gsd-core/workflows/help.md +++ b/gsd-core/workflows/help.md @@ -10,7 +10,7 @@ Display GSD command help at the tier the user asked for. Output ONLY the referen | When `$ARGUMENTS` is | Read | |---|---| | `--brief` (or `-b`) alone | `workflows/help/modes/brief.md` | -| `--full` (or `-f`, `--all`) alone | `workflows/help/modes/full.md` | +| `--full` (or `-f`, `--all`) alone | `workflows/help/modes/full.md` (or its `workflows/help/modes/full.compact.md` variant per `gsd-core/references/compact-content-gate.md` §"Streams 1b and 4 — variant resolution") | | empty / unset | `workflows/help/modes/default.md` | | `--brief ` (or `-b `) | `workflows/help/modes/topic.md` in compact scope (signature + one-line summary of the matched section) | | anything else — bare topic, `--full `, or topic with leading `--` | `workflows/help/modes/topic.md` in full scope (entire matched section) | diff --git a/gsd-core/workflows/help/modes/full.compact.md b/gsd-core/workflows/help/modes/full.compact.md new file mode 100644 index 000000000..8806a72e9 --- /dev/null +++ b/gsd-core/workflows/help/modes/full.compact.md @@ -0,0 +1,398 @@ +Apply response_language to all user-facing prose — narration between tool calls, status updates, progress notes, and findings included; preserve code, paths, and identifiers. + + +Display the complete GSD Core command reference. Output ONLY the reference content. Do NOT add project-specific analysis, git status, next-step suggestions, or any commentary beyond the reference. + + + +# GSD Core Command Reference + +**GSD Core** (Git. Ship. Done.) creates hierarchical project plans optimized for solo agentic development with Claude Code. + +## Quick Start + +1. `/gsd:new-project` — Initialize project (research, requirements, roadmap) +2. `/gsd:plan-phase 1` — Create detailed plan for first phase +3. `/gsd:execute-phase 1` — Execute the phase + +Not sure where to start? `/gsd:next` reads your project state and routes you to the right next action. + +### Smart Entry + +**`/gsd:next`** — State-aware front door. Detects your situation via `gsd-tools smart-entry` (no-project, paused, blocked, planning, executing, needs-verify, idle, complete, …) and shows a menu with one recommended action. Launcher only; falls back to `/gsd:progress`. + +Usage: `/gsd:next` + +## Staying Updated + +```bash +npx @opengsd/gsd-core@latest +``` + +## Core Workflow + +```text +/gsd:new-project → /gsd:plan-phase → /gsd:execute-phase → repeat +``` + +### Project Initialization + +**`/gsd:new-project`** — Unified flow from idea to ready-for-planning: deep questioning, optional domain research (4 parallel researchers), requirements with v1/v2/out-of-scope scoping, roadmap with phase breakdown. Creates `.planning/`: `PROJECT.md`, `config.json`, `research/`, `REQUIREMENTS.md`, `ROADMAP.md`, `STATE.md`. + +Usage: `/gsd:new-project` + +**`/gsd:onboard [--fast] [--text]`** — Guides first-time onboarding for an existing codebase: detects brownfield state, routes through `/gsd:map-codebase` → `/gsd:ingest-docs` → `/gsd:new-project` in safe order, idempotent. + +Usage: `/gsd:onboard` + +**`/gsd:map-codebase [--fast] [--focus ] [--query ]`** — Maps an existing codebase with parallel Explore agents into `.planning/codebase/` (stack, architecture, structure, conventions, testing, integrations, concerns). `--fast` for rapid assessment, `--query` to search the intel index. + +Usage: `/gsd:map-codebase` + +### Phase Planning + +**`/gsd:discuss-phase [--chain | --analyze | --power | --assumptions] [--batch[=N]]`** — Articulate your vision for a phase before planning; creates CONTEXT.md. `--chain` chained flow, `--analyze` assumption analysis, `--power` extended questions, `--assumptions` surfaces implementation assumptions non-interactively, `--batch` groups 2-5 questions per turn. + +Usage: `/gsd:discuss-phase 2` +Usage: `/gsd:discuss-phase 2 --batch=3` + +**`/gsd:plan-phase [--research] [--skip-research] [--research-phase ] [--view] [--gaps] [--skip-verify] [--skip-ui] [--prd ] [--ingest ] [--ingest-format ] [--reviews] [--text] [--bounce] [--skip-bounce] [--chunked] [--tdd] [--mvp] [--granularity ] [--no-tracer] [--no-reversibility-gates]`** — Creates `.planning/phases/XX-phase-name/XX-YY-PLAN.md` with concrete tasks, verification criteria, and success measures (multiple plans per phase supported). + +Key flags: `--research-phase ` runs research only and writes `RESEARCH.md` then exits (replaces the deleted `gsd-research-phase`; `--research` forces refresh, `--view` prints existing without spawning). `--gaps` closes gaps from a prior plan-check. `--ingest`/`--ingest-format` pre-ingest external ADRs/PRDs/SPECs (see PRD Express Path). `--bounce`/`--skip-bounce` toggle the optional external refinement pass (`workflow.plan_bounce`). `--chunked` splits planning into short, individually-committed passes for crash resilience (`workflow.plan_chunked`), resumable. `--tdd` tests-before-code order. `--mvp` adds user story + Walking Skeleton (see `/gsd:mvp-phase`). `--granularity` overrides resolved plan granularity. `--no-tracer` opts out of tracer-first ordering. `--no-reversibility-gates` suppresses the one-way-door checkpoint for unattended runs. + +Usage: `/gsd:plan-phase 1` +Result: Creates `.planning/phases/01-foundation/01-01-PLAN.md` + +**PRD Express Path:** Pass `--prd path/to/requirements.md` to skip discuss-phase — your PRD becomes locked decisions in CONTEXT.md. + +### Execution + +**`/gsd:execute-phase [--wave N] [--gaps-only] [--tdd]`** — Groups plans by wave (frontmatter), executes sequentially with parallel plans per wave via Task tool, verifies phase goal, updates REQUIREMENTS/ROADMAP/STATE. `--wave N` runs only wave N; `--gaps-only` re-runs verifier-flagged plans; `--tdd` enforces test-driven order. + +Usage: `/gsd:execute-phase 5` +Usage: `/gsd:execute-phase 5 --wave 2` + +### Smart Router + +**`/gsd:progress --do ""`** — Routes freeform text to the best-matching GSD command; asks you to pick between top matches on ambiguity. Never does the work itself. + +Usage: `/gsd:progress --do "fix the login button"` + +### Quick Mode + +**`/gsd:quick [--full] [--validate] [--discuss] [--research]`** — Small ad-hoc tasks in `.planning/quick/` (updates STATE.md, not ROADMAP.md); spawns planner+executor only by default. `--full` = discuss+research+plan-check+verify; `--validate` = plan-check + post-execution verify; `--discuss`/`--research` add one step each; flags compose. + +Usage: `/gsd:quick` +Result: Creates `.planning/quick/NNN-slug/PLAN.md`, `.planning/quick/NNN-slug/NNN-slug-SUMMARY.md` + +--- + +**`/gsd:quick-batch [--file ] [--jobs auto|N] [--validate] [--research] [--resume ] [task list]`** — Batches several quick-shaped tasks (inline or `--file`); one coordinator plans/dispatches/merges. `--jobs` caps concurrency, `--resume` dispatches only eligible items; `--discuss`/`--full` are rejected. + +Usage: `/gsd:quick-batch --jobs 3 --validate` +Result: Per-item artifacts under `.planning/quick/`; batch state in `.planning/quick-batches//BATCH.json` + +--- + +**`/gsd:fast [description]`** — Trivial task inline, no subagent, no planning files: typo fixes, config changes, ≤3 file edits (redirects to `/gsd:quick` above that). Atomic commit, logs to STATE.md. + +Usage: `/gsd:fast "fix the typo in README"` + +### Roadmap Management + +**`/gsd:phase `** — Appends a new phase (next sequential number) to ROADMAP.md. + +Usage: `/gsd:phase "Add admin dashboard"` + +**`/gsd:phase --insert `** — Inserts a decimal phase (e.g. 7.1) between existing phases for discovered mid-milestone work. + +Usage: `/gsd:phase --insert 7 "Fix critical auth bug"` +Result: Creates Phase 7.1 + +**`/gsd:phase --remove `** — Deletes a future (unstarted) phase and renumbers subsequent phases; git commit preserves history. + +Usage: `/gsd:phase --remove 17` +Result: Phase 17 deleted, phases 18-20 become 17-19 + +**`/gsd:phase --edit [--force]`** — Edits title/description/requirements/dependencies in place; `--force` allows editing already-started phases. + +### Milestone Management + +**`/gsd:new-milestone `** — Mirrors `/gsd:new-project`'s flow for brownfield (existing PROJECT.md): questioning, optional research, requirements, roadmap. `--reset-phase-numbers` restarts at Phase 1 (archives old dirs first); `--ws ` scopes to a workstream, skipping the shared PROJECT.md write. + +Usage: `/gsd:new-milestone "v2.0 Features"` + +**`/gsd:complete-milestone `** — Archives to MILESTONES.md + milestones/ dir, tags the release, preps workspace for next version. + +Usage: `/gsd:complete-milestone 1.0.0` + +### Progress Tracking + +**`/gsd:progress [--next | --forensic | --do ""]`** — Progress bar, SUMMARY recap, current position, key decisions, offers to execute/create next plan, detects 100% completion. + +Modes: default (report+routing) · `--next` (auto-advance; `--force` bypasses safety gates) · `--next --auto` (chains steps until milestone completion or a blocking decision) · `--next --converge` (routes planning through `/gsd:plan-review-convergence`, requires `workflow.plan_review_convergence`; reviewer flags and `--max-cycles` forward) · `--forensic` (appends a 6-check integrity audit) · `--do ""` (smart router, see above). + +Usage: `/gsd:progress` +Usage: `/gsd:progress --next --auto` + +### Session Management + +**`/gsd:resume-work`** — Reads STATE.md, shows position and recent progress, offers next actions. + +Usage: `/gsd:resume-work` + +**`/gsd:pause-work [--report]`** — Creates a `.continue-here` handoff, updates STATE.md's session-continuity section. `--report` also writes a post-session summary to `.planning/reports/`. + +Usage: `/gsd:pause-work` + +### Debugging + +**`/gsd:debug [issue description] [--diagnose]`** — Adaptive-question symptom gathering, `.planning/debug/[slug].md` tracking, scientific-method investigation, survives `/clear` (resume with no args), archives resolved issues. `--diagnose` runs a one-shot pass without a persistent session. + +Usage: `/gsd:debug "login button doesn't work"` + +### Spiking & Sketching + +**`/gsd:spike [idea] [--quick]`** — Decomposes into 2-5 risk-ordered Given/When/Then experiments, builds minimum code, captures VALIDATED/INVALIDATED/PARTIAL, saves to `.planning/spikes/` with MANIFEST.md. Works in any repo, no `/gsd:new-project` needed. `--quick` skips decomposition. + +Usage: `/gsd:spike "can we stream LLM output over WebSockets?"` + +**`/gsd:sketch [idea] [--quick]`** — Conversational mood intake, 2-3 tabbed HTML variants per sketch, shared CSS theme system, saves to `.planning/sketches/` with MANIFEST.md. `--quick` skips mood intake. + +Usage: `/gsd:sketch "dashboard layout for the admin panel"` + +**`/gsd:spike --wrap-up`** — Curates spikes one-at-a-time (include/exclude/partial/UAT), generates a project skill under `./.claude/skills/spike-findings-[project]/`, writes `.planning/spikes/WRAP-UP-SUMMARY.md`, adds a CLAUDE.md auto-load line. + +Usage: `/gsd:spike --wrap-up` + +**`/gsd:sketch --wrap-up`** — Same curation flow for sketches, generating `./.claude/skills/sketch-findings-[project]/` with design decisions/CSS/HTML structures. + +Usage: `/gsd:sketch --wrap-up` + +### Capturing Ideas, Notes, and Todos + +**`/gsd:capture [description]`** — Extracts context from conversation (or uses the given text), creates a todo in `.planning/todos/pending/`, infers area, checks duplicates, updates STATE.md count. + +Usage: `/gsd:capture Add auth token refresh` + +**`/gsd:capture --note `** — Zero-friction timestamped note to `.planning/notes/` (or `~/.claude/notes/` globally). Subcommands: append (default), list, promote (note → todo). Works without a project. + +Usage: `/gsd:capture --note refactor the hook system` +Usage: `/gsd:capture --note promote 3` + +**`/gsd:capture --list [area]`** — Lists pending todos (optional area filter), loads full context for the one you pick, routes to work-now/add-to-phase/brainstorm, moves it to completed/ on start. + +Usage: `/gsd:capture --list api` + +**`/gsd:capture --list-seeds [status]`** — Read-only listing of captured seeds (ID, status, scope, trigger, title); optional status filter. Enrich via `/gsd:capture --seed --enrich SEED-NNN`. + +Usage: `/gsd:capture --list-seeds dormant` + +### User Acceptance Testing + +**`/gsd:verify-work [phase]`** — Extracts testable deliverables from SUMMARY.md, presents tests one at a time (yes/no), auto-diagnoses failures into fix plans, ready for re-execution. + +Usage: `/gsd:verify-work 3` + +### Ship Work + +**`/gsd:ship [phase]`** — Pushes branch, opens a PR with a body from SUMMARY/VERIFICATION/REQUIREMENTS, optionally requests review, updates STATE.md. Requires a verified phase and authenticated `gh`. + +Usage: `/gsd:ship 4` or `/gsd:ship 4 --draft` + +--- + +**`/gsd:review --phase N [--gemini] [--claude] [--codex] [--coderabbit] [--opencode] [--qwen] [--cursor] [--agy] [--all]`** — Detects available external AI CLIs, each independently reviews the phase's plans with the same structured prompt (CodeRabbit reviews the live diff, up to ~5 min), produces REVIEWS.md with consensus. Feed back via `/gsd:plan-phase N --reviews`. + +Usage: `/gsd:review --phase 3 --all` + +--- + +**`/gsd:pr-branch [target]`** — Classifies commits (code-only/planning-only/mixed), cherry-picks code onto a clean branch so reviewers see no `.planning/` artifacts. + +Usage: `/gsd:pr-branch` or `/gsd:pr-branch main` + +--- + +**`/gsd:capture --seed [idea]`** — Captures a forward-looking idea with WHY/WHEN-to-surface trigger conditions; auto-surfaces during `/gsd:new-milestone` when triggers match. + +Usage: `/gsd:capture --seed "add real-time notifications when we build the events system"` + +**`/gsd:capture --backlog [description]`** — Adds an idea to the 999.x backlog without committing to the current milestone; promote later via `/gsd:review-backlog`. + +Usage: `/gsd:capture --backlog "real-time notifications when events ship"` + +--- + +**`/gsd:audit-uat`** — Cross-phase audit of all outstanding UAT/verification items (pending, skipped, blocked, human_needed), cross-references the codebase for stale docs, produces a prioritized test plan. Run before a new milestone. + +Usage: `/gsd:audit-uat` + +### Milestone Auditing + +**`/gsd:audit-milestone [version]`** — Reads all phase VERIFICATION.md files, checks requirements coverage, spawns an integration checker for cross-phase wiring, creates MILESTONE-AUDIT.md. + +Usage: `/gsd:audit-milestone` + +### Configuration + +**`/gsd:settings`** — Interactively toggles researcher/plan-checker/verifier agents and the model profile (quality/balanced/budget/inherit); updates `.planning/config.json`. + +Usage: `/gsd:settings` + +**`/gsd:config [--profile | --advanced | --integrations]`** — `--profile` quick-switches model profile (`quality` = Opus everywhere but verification, `balanced` = Opus planning/Sonnet execution (default), `budget` = Sonnet writing/Haiku research-verification, `inherit` = current session model). `--advanced` = plan bounce, timeouts, branch templates, cross-AI execution. `--integrations` = third-party API keys, code-review CLI routing, agent-skill injection. + +Usage: `/gsd:config --profile budget` + +**`/gsd:surface [list|status|profile |disable |enable |reset]`** — Toggles which skills are surfaced without reinstalling: `list`/`status` show enabled/disabled + token cost, `profile ` switches base profile (`core`/`standard`/`full`), `disable`/`enable` a cluster, `reset` returns to install-time profile. + +Usage: `/gsd:surface profile standard` + +### Utility Commands + +**`/gsd:cleanup`** — Dry-run then moves completed-milestone phase dirs from `.planning/phases/` to `.planning/milestones/v{X.Y}-phases/`. + +Usage: `/gsd:cleanup` + +**`/gsd:help [--brief | --full | | --brief ]`** — `--brief` = ~10-line refresher; no flag = one-page newcomer tour; `--full` = this complete reference; `` = matching section only (e.g. `/gsd:help debug`); `--brief ` = compact scoped lookup. Every topic output starts with a `**Topic:** \`\` → \`\` *(scope: full | compact)*` preamble. See `gsd-core/workflows/help/modes/topic.md` for the alias table. + +Usage: `/gsd:help debug` +Usage: `/gsd:help --brief debug` + +**`/gsd:update [--sync] [--reapply] [--next | --rc]`** — Shows installed-vs-latest, changelog since your version, breaking changes, confirms before installing. `--sync` syncs managed skills across runtime roots; `--reapply` reapplies local modifications post-update; `--next`/`--rc` installs from the `@next` RC dist-tag (ADR #660) instead of `@latest`. + +Usage: `/gsd:update` + +## Additional Commands + +Every command below is also a live `/gsd-*` slash command, grouped by purpose. + +### Discovery & Specification + +- **`/gsd:explore`** — Socratic ideation and idea routing before committing to plans. +- **`/gsd:spec-phase [--auto] [--text]`** — Clarify WHAT a phase delivers with ambiguity scoring; produces SPEC.md before discuss-phase. +- **`/gsd:ai-integration-phase [phase]`** — Generate an AI-SPEC.md design contract for phases building AI systems. +- **`/gsd:ui-phase [phase]`** — Generate UI design contract (UI-SPEC.md) for frontend phases. +- **`/gsd:import --from | --from-gsd2`** — Ingest external plans with conflict detection, or reverse-migrate a GSD-2 project to v1 format. +- **`/gsd:ingest-docs [path] [--mode new|merge] [--manifest ] [--resolve auto|interactive]`** — Bootstrap or merge `.planning/` from existing ADRs/PRDs/SPECs/docs. + +### Planning & Execution + +- **`/gsd:mvp-phase `** — Plans a phase as a vertical MVP slice (user story + SPIDR splitting) before handoff to plan-phase; same end-state as `/gsd:plan-phase --mvp` with a guided intro. +- **`/gsd:ultraplan-phase [phase]`** — [BETA] Offload plan phase to Claude Code's ultraplan cloud; review in browser, import back. +- **`/gsd:plan-review-convergence [--gemini] [--claude] [--codex] [--coderabbit] [--opencode] [--qwen] [--cursor] [--agy/--antigravity] [--ollama] [--lm-studio] [--llama-cpp] [--kimi-code] [--all] [--text] [--ws ] [--max-cycles N]`** — Cross-AI convergence loop: replan with review feedback until no HIGH concerns remain (cloud and local-model reviewers). +- **`/gsd:autonomous [--from N] [--to N] [--only N] [--interactive] [--converge]`** — Runs all remaining phases unattended: discuss → plan → execute per phase; `--converge`/`--cross-ai` routes planning through convergence. + +### Quality, Review & Verification + +- **`/gsd:code-review [--depth=quick|standard|deep] [--files file1,file2,...] [--fix [--all] [--auto]]`** — Reviews phase-changed source for bugs, security, quality. +- **`/gsd:secure-phase [phase]`** — Retroactively verifies threat mitigations for a completed phase. +- **`/gsd:validate-phase [phase]`** — Retroactively audits and fills Nyquist validation gaps. +- **`/gsd:ui-review [phase]`** — Retroactive 6-pillar visual audit of implemented frontend code. +- **`/gsd:eval-review [phase]`** — Audits an executed AI phase's evaluation coverage; produces EVAL-REVIEW.md. +- **`/gsd:audit-fix --source [--severity medium|high|all] [--max N] [--dry-run]`** — Autonomous audit-to-fix: find, classify, fix, test, commit. +- **`/gsd:add-tests [additional instructions]`** — Generates tests for a completed phase from UAT criteria and implementation. + +### Diagnostics & Maintenance + +- **`/gsd:health [--repair] [--context]`** — Diagnoses planning-directory health, optionally repairs. +- **`/gsd:forensics [problem description]`** — Post-mortem investigation for failed GSD workflows. +- **`/gsd:undo --last N | --phase NN | --plan NN-MM`** — Safe git revert using the phase manifest with dependency checks. +- **`/gsd:docs-update [--force] [--verify-only]`** — Generates/updates docs verified against the codebase. +- **`/gsd:extract-learnings `** — Extracts decisions, lessons, patterns, surprises from phase artifacts. + +### Knowledge & Context + +- **`/gsd:graphify [build|query |status|diff]`** — Builds/queries/inspects the project knowledge graph in `.planning/graphs/`. +- **`/gsd:mempalace-recall`** — Recalls prior decisions/patterns/surprises from MemPalace before planning. +- **`/gsd:mempalace-capture [artifact-type]`** — Files a phase artifact into MemPalace, mirrors decisions into its temporal KG. +- **`/gsd:thread [list [--open|--resolved] | close | status | name | description]`** — Manages persistent context threads across sessions. +- **`/gsd:profile-user [--questionnaire] [--refresh]`** — Generates a developer behavioral profile + Claude-discoverable artifacts. +- **`/gsd:stats`** — Project statistics: phases, plans, requirements, git metrics, timeline. + +### Workflow & Orchestration + +- **`/gsd:manager [--analyze-deps]`** — Interactive command center for multiple phases from one terminal; `--analyze-deps` scans dependency relationships before parallel execution. +- **`/gsd:workspace [--new | --list | --remove] [name]`** — Creates/lists/removes isolated GSD workspace environments. +- **`/gsd:workstreams`** — List, create, switch, status, progress, complete, and resume parallel workstreams. +- **`/gsd:review-backlog`** — Reviews and promotes backlog items to the active milestone. +- **`/gsd:milestone-summary [version]`** — Comprehensive project summary from milestone artifacts, for onboarding/review. + +### Repository Integration + +- **`/gsd:inbox [--issues] [--prs] [--label] [--close-incomplete] [--repo owner/repo]`** — Triages open GitHub issues/PRs against project templates and contribution guidelines. + +### Namespace Routers (model-facing meta-skills) + +Six skills for two-stage hierarchical routing across 60+ skills; invoke directly to browse a category interactively: + +- **`/gsd-context`** — Codebase intelligence (map, graphify, docs, learnings, mempalace). +- **`/gsd-ideate`** — Exploration/capture (explore, sketch, spike, spec, capture). +- **`/gsd-manage`** — Configuration/workspace (workstreams, thread, update, ship, inbox). +- **`/gsd-project`** — Project-lifecycle (milestones, audits, summary). +- **`/gsd-quality`** — Quality gates (code review, debug, audit, security, eval, ui). +- **`/gsd-workflow`** — Phase pipeline (discuss, plan, execute, verify, phase, progress). + +## Files & Structure + +```text +.planning/ +├── PROJECT.md # Project vision +├── ROADMAP.md # Current phase breakdown +├── STATE.md # Project memory & context +├── RETROSPECTIVE.md # Living retrospective (updated per milestone) +├── config.json # Workflow mode & gates +├── todos/ # Captured ideas and tasks (pending/, completed/) +├── spikes/ # Spike experiments — MANIFEST.md + NNN-name/ dirs +├── sketches/ # Design sketches — MANIFEST.md, themes/, NNN-name/ dirs +├── debug/ # Active debug sessions (resolved/ archive) +├── milestones/ # Archived roadmap/requirements snapshots + v{X.Y}-phases/ +├── codebase/ # Codebase map (brownfield): STACK/ARCHITECTURE/STRUCTURE/ +│ # CONVENTIONS/TESTING/INTEGRATIONS/CONCERNS.md +└── phases/ # 01-foundation/01-01-PLAN.md + -SUMMARY.md, etc. +``` + +## Workflow Modes + +Set during `/gsd:new-project`, changeable anytime in `.planning/config.json`: + +- **Interactive** — confirms each major decision, pauses at checkpoints, more guidance. +- **YOLO** — auto-approves most decisions, executes without confirmation, stops only for critical checkpoints. + +## Planning Configuration + +`.planning/config.json`: + +- **`planning.commit_docs`** (default `true`) — `false` keeps planning artifacts local-only (add `.planning/` to `.gitignore`); useful for OSS/client projects wanting private planning. +- **`planning.search_gitignored`** (default `false`) — `true` adds `--no-ignore` to broad ripgrep searches when `.planning/` is gitignored. + +```json +{ + "planning": { + "commit_docs": false, + "search_gitignored": true + } +} +``` + +## Common Workflows + +**New project:** `/gsd:new-project` → `/clear` → `/gsd:plan-phase 1` → `/clear` → `/gsd:execute-phase 1` + +**Resuming:** `/gsd:progress` + +**Urgent mid-milestone work:** `/gsd:phase --insert 5 "Critical security fix"` → `/gsd:plan-phase 5.1` → `/gsd:execute-phase 5.1` + +**Completing a milestone:** `/gsd:complete-milestone 1.0.0` → `/clear` → `/gsd:new-milestone` + +**Capturing ideas:** `/gsd:capture` (from context) · `/gsd:capture --note ...` (quick note) · `/gsd:capture --seed "..."` (forward-looking) · `/gsd:capture --list` (review) + +**Debugging:** `/gsd:debug "symptom"` → (investigate, context fills) → `/clear` → `/gsd:debug` (resumes) + +## Getting Help + +- Read `.planning/PROJECT.md` for project vision +- Read `.planning/STATE.md` for current context +- Check `.planning/ROADMAP.md` for phase status +- Run `/gsd:progress` to check where you're up to + diff --git a/package.json b/package.json index 677557620..99019bdcc 100644 --- a/package.json +++ b/package.json @@ -140,6 +140,7 @@ "lint:hooks-runtime-build-seam": "node scripts/lint-hooks-runtime-build-seam.cjs", "ci:test-scope": "node scripts/ci-test-scope.cjs", "benchmark:compact-content": "node scripts/benchmark-compact-content.cjs --check", + "benchmark:compact-content-variants": "node scripts/benchmark-compact-content-variants.cjs --check", "changeset": "node scripts/changeset/new.cjs", "changelog:render": "node scripts/changeset/cli.cjs render", "test": "node scripts/run-tests.cjs", diff --git a/scripts/benchmark-compact-content-variants.cjs b/scripts/benchmark-compact-content-variants.cjs new file mode 100644 index 000000000..67730c14b --- /dev/null +++ b/scripts/benchmark-compact-content-variants.cjs @@ -0,0 +1,291 @@ +#!/usr/bin/env node +'use strict'; + +/** + * Benchmarks the token-count effect of every registered variant-swap pair + * (ADR-4139, Phase 6 #4406 — `gsd-core/workflows//{modes,steps,templates}/*.compact.md` + * and `gsd-core/templates/**\/*.compact.md`). Sibling to + * `scripts/benchmark-compact-content.cjs` (Phase 3's spine/detail benchmark) rather than an + * extension of it — the two are different data shapes (a variant pair is two independent, + * deliberately-overlapping complete files; a spine/detail split is one document partitioned in + * two disjoint halves), and mixing them into one report would conflate an "off" total that means + * something different in each case. + * + * For every registered pair it reports the "off" token count (the canonical file — what + * `workflow.compact_content=false`, the default, pays at that call site) against the "on" token + * count (the `.compact.md` sibling — what `workflow.compact_content=true` pays once the gate + * resolves to it). See `gsd-core/references/compact-content-gate.md` §"Streams 1b and 4" for the + * resolution rule this measures. + * + * PROXY-TOKENIZER CAVEAT: same as the sibling script — `gpt-tokenizer` is a stand-in tokenizer; + * Anthropic publishes none for Claude 3+. The on/off comparison is exact under this one pinned + * tokenizer applied identically to both sides; the absolute counts are not Claude's real counts. + * + * Discovery is REIMPLEMENTED here rather than imported from + * `tests/helpers/compact-content-variant.cjs`, for the same reason + * `benchmark-compact-content.cjs` reimplements spine/detail discovery instead of importing it: a + * `scripts/` reporting tool depending on a test-only helper module inverts this repo's normal + * layering, and a test-only module changing shape should never be able to break a benchmark. + * + * Usage: + * node scripts/benchmark-compact-content-variants.cjs # print JSON to stdout + * node scripts/benchmark-compact-content-variants.cjs --write # write the committed baseline + * node scripts/benchmark-compact-content-variants.cjs --check # recompute, diff vs committed baseline + * node scripts/benchmark-compact-content-variants.cjs --check --baseline-path= + * + * Same CRITICAL contract as the sibling script: this — and `--check` especially — MUST NEVER + * exit non-zero because a baseline is drifted, stale, or missing. Only a genuine I/O error + * reading a SOURCE file the benchmark measures may throw. This is a reporting instrument, never + * a gate. + */ + +const fs = require('node:fs'); +const path = require('node:path'); + +const { countTokens } = require('gpt-tokenizer'); +const { runMain } = require('./lib/cli-exit.cjs'); + +const ROOT = path.resolve(__dirname, '..'); +const VARIANT_ROOTS = [path.join(ROOT, 'gsd-core', 'workflows'), path.join(ROOT, 'gsd-core', 'templates')]; +const BASELINE_PATH = path.join(ROOT, 'tests', 'fixtures', 'compact-content-variant-benchmark-baseline.json'); +const COMPACT_SUFFIX = '.compact.md'; + +function getTokenizerVersion() { + const pkgPath = require.resolve('gpt-tokenizer/package.json'); + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + return pkg.version; +} + +/** + * Discover every registered variant pair under `roots`: a `.compact.md` file + * with a same-directory, same-stem canonical `.md` sibling. Mirrors + * `tests/helpers/compact-content-variant.cjs`'s `discoverRegisteredVariants` + * in shape but is a from-scratch, self-contained implementation (see module + * header for why this is not a shared import). Skips an orphaned compact + * file with no canonical sibling — that is the guard's problem, not this + * benchmark's; a pair with no canonical baseline has no "off" number to + * report against. + * + * @param {string[]} [roots] + * @returns {Array<{name: string, canonicalPath: string, compactPath: string}>} + */ +function discoverRegisteredVariants(roots = VARIANT_ROOTS) { + const pairs = []; + + function walk(dir) { + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(full); + } else if (entry.isFile() && entry.name.endsWith(COMPACT_SUFFIX)) { + const stem = entry.name.slice(0, -COMPACT_SUFFIX.length); + const canonicalPath = path.join(dir, `${stem}.md`); + if (fs.existsSync(canonicalPath)) { + const name = path.relative(ROOT, canonicalPath).split(path.sep).join('/'); + pairs.push({ name, canonicalPath, compactPath: full }); + } + } + } + } + + for (const root of roots) walk(root); + return pairs.sort((a, b) => a.name.localeCompare(b.name)); +} + +/** + * Compute the off/on/reduction numbers for ONE registered variant pair. + * Throws on a genuine read failure of a source file (the one thing allowed + * to throw, per the module-header CRITICAL note). + * + * @param {{canonicalPath: string, compactPath: string}} pair + * @returns {{offTokens: number, onTokens: number, reductionPct: number}} + */ +function computePairTokens(pair) { + const offTokens = countTokens(fs.readFileSync(pair.canonicalPath, 'utf8')); + const onTokens = countTokens(fs.readFileSync(pair.compactPath, 'utf8')); + const reductionPct = offTokens === 0 ? 0 : round2(((offTokens - onTokens) / offTokens) * 100); + return { offTokens, onTokens, reductionPct }; +} + +function round2(n) { + return Math.round(n * 100) / 100; +} + +/** + * Aggregate per-pair numbers from SUMMED off/on totals, never averaged + * per-pair percentages. Reports `0` (not `NaN`) for zero registered pairs. + * @param {Record} pairResults + */ +function computeAggregate(pairResults) { + let offTokens = 0; + let onTokens = 0; + for (const key of Object.keys(pairResults)) { + offTokens += pairResults[key].offTokens; + onTokens += pairResults[key].onTokens; + } + const reductionPct = offTokens === 0 ? 0 : round2(((offTokens - onTokens) / offTokens) * 100); + return { offTokens, onTokens, reductionPct }; +} + +const LABEL = + "PROXY-TOKENIZER DELTA — gpt-tokenizer is a stand-in; Anthropic publishes no tokenizer for Claude 3+. " + + "The on/off COMPARISON is exact under this pinned tokenizer; absolute counts are not Claude's real token counts."; + +/** + * @param {string[]} [roots] + * @returns {object} + */ +function buildReport(roots = VARIANT_ROOTS) { + const pairs = discoverRegisteredVariants(roots); + const pairReports = {}; + for (const pair of pairs) { + pairReports[pair.name] = computePairTokens(pair); + } + return { + schema_version: 1, + generated_by: 'scripts/benchmark-compact-content-variants.cjs', + tokenizer: { name: 'gpt-tokenizer', version: getTokenizerVersion() }, + label: LABEL, + pairs: pairReports, + aggregate: computeAggregate(pairReports), + }; +} + +/** + * Format a human-readable drift summary between a (possibly missing/invalid) + * committed baseline and a freshly-computed live report. Never throws. + * @param {string} baselinePath + * @param {object} live + * @returns {string} + */ +function formatDriftReport(baselinePath, live) { + const lines = []; + let baseline = null; + let baselineReadError = null; + try { + const raw = fs.readFileSync(baselinePath, 'utf8'); + try { + baseline = JSON.parse(raw); + } catch (parseErr) { + baselineReadError = `baseline at ${baselinePath} could not be parsed as JSON: ${parseErr.message}`; + } + } catch { + baselineReadError = `no baseline found at ${baselinePath}`; + } + + if (baselineReadError) { + lines.push(`DRIFT: ${baselineReadError} — treating as fully drifted (this is reported, not an error).`); + lines.push('Live pairs:'); + for (const name of Object.keys(live.pairs).sort()) { + const p = live.pairs[name]; + lines.push(` + ${name}: off=${p.offTokens} on=${p.onTokens} reduction=${p.reductionPct}%`); + } + lines.push( + `Live aggregate: off=${live.aggregate.offTokens} on=${live.aggregate.onTokens} ` + + `reduction=${live.aggregate.reductionPct}%`, + ); + return lines.join('\n'); + } + + const baselinePairs = (baseline && typeof baseline === 'object' && baseline.pairs) || {}; + const livePairs = live.pairs; + const allNames = new Set([...Object.keys(baselinePairs), ...Object.keys(livePairs)]); + let anyDrift = false; + + if (!baseline || typeof baseline.label !== 'string' || !baseline.label.includes('PROXY-TOKENIZER')) { + anyDrift = true; + lines.push('DRIFT: committed baseline is missing the required "PROXY-TOKENIZER" label.'); + } + + for (const name of [...allNames].sort()) { + const b = baselinePairs[name]; + const l = livePairs[name]; + if (!b) { + anyDrift = true; + lines.push(`DRIFT: pair "${name}" is new (not in committed baseline) — live off=${l.offTokens} on=${l.onTokens} reduction=${l.reductionPct}%`); + } else if (!l) { + anyDrift = true; + lines.push(`DRIFT: pair "${name}" was removed (present in committed baseline, not found live) — baseline off=${b.offTokens} on=${b.onTokens} reduction=${b.reductionPct}%`); + } else if (b.offTokens !== l.offTokens || b.onTokens !== l.onTokens || b.reductionPct !== l.reductionPct) { + anyDrift = true; + lines.push( + `DRIFT: pair "${name}": off ${b.offTokens} -> ${l.offTokens} (${l.offTokens - b.offTokens >= 0 ? '+' : ''}${l.offTokens - b.offTokens}), ` + + `on ${b.onTokens} -> ${l.onTokens} (${l.onTokens - b.onTokens >= 0 ? '+' : ''}${l.onTokens - b.onTokens}), ` + + `reduction ${b.reductionPct}% -> ${l.reductionPct}% (${round2(l.reductionPct - b.reductionPct) >= 0 ? '+' : ''}${round2(l.reductionPct - b.reductionPct)}pp)`, + ); + } + } + + const ba = (baseline && baseline.aggregate) || {}; + const la = live.aggregate; + if (ba.offTokens !== la.offTokens || ba.onTokens !== la.onTokens || ba.reductionPct !== la.reductionPct) { + anyDrift = true; + lines.push( + `DRIFT: aggregate: off ${ba.offTokens} -> ${la.offTokens}, on ${ba.onTokens} -> ${la.onTokens}, ` + + `reduction ${ba.reductionPct}% -> ${la.reductionPct}%`, + ); + } + + if (!anyDrift) { + lines.push(`Baseline at ${baselinePath} is up to date with the live recompute.`); + } else { + lines.push(''); + lines.push('Run `node scripts/benchmark-compact-content-variants.cjs --write` to refresh the committed baseline.'); + lines.push('(This is a REPORT, not a gate — exiting 0 regardless of drift, per this script\'s own contract.)'); + } + return lines.join('\n'); +} + +function parseArgs(argv) { + const opts = { write: false, check: false, baselinePath: BASELINE_PATH }; + for (const arg of argv) { + if (arg === '--write') opts.write = true; + else if (arg === '--check') opts.check = true; + else if (arg.startsWith('--baseline-path=')) opts.baselinePath = arg.slice('--baseline-path='.length); + } + return opts; +} + +function main() { + const opts = parseArgs(process.argv.slice(2)); + + if (opts.write) { + const report = buildReport(); + fs.mkdirSync(path.dirname(BASELINE_PATH), { recursive: true }); + fs.writeFileSync(BASELINE_PATH, JSON.stringify(report, null, 2) + '\n'); + process.stdout.write(`Wrote ${BASELINE_PATH}\n`); + return; + } + + if (opts.check) { + const live = buildReport(); + process.stdout.write(formatDriftReport(opts.baselinePath, live) + '\n'); + return; + } + + process.stdout.write(JSON.stringify(buildReport(), null, 2) + '\n'); +} + +/* c8 ignore next 3 -- CLI entry guard; this repo measures coverage with c8, which does not honor istanbul pragmas */ +if (require.main === module) { + runMain(main); +} + +module.exports = { + discoverRegisteredVariants, + computePairTokens, + computeAggregate, + buildReport, + formatDriftReport, + getTokenizerVersion, + parseArgs, + LABEL, + BASELINE_PATH, + VARIANT_ROOTS, +}; diff --git a/tests/codex-config-agents.test.cjs b/tests/codex-config-agents.test.cjs new file mode 100644 index 000000000..9ac61a368 --- /dev/null +++ b/tests/codex-config-agents.test.cjs @@ -0,0 +1,2350 @@ +/** + * GSD Tools Tests - codex-config.cjs + * + * Tests for Codex adapter header, agent conversion, config.toml generation/merge, + * per-agent .toml generation, and uninstall cleanup. + */ + +// Enable test exports from install.js (skips main CLI logic) +process.env.GSD_TEST_MODE = '1'; + +const { test, describe, before, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { runNode } = require('./helpers/process-seam.cjs'); +const { throwIfFailed } = require('./helpers/git-fixture.cjs'); +const { cleanup } = require('./helpers.cjs'); +const _fc = require('fast-check'); +const { CLAUDE_AGENT_ALIASES: _CLAUDE_AGENT_ALIASES } = require('../gsd-core/bin/lib/model-resolver.cjs'); +const { escapeRegex } = require('../gsd-core/bin/lib/pattern.cjs'); +// #3241 — the intended new home for CLAUDE_AGENT_ALIASES + isAnthropicFlavoredModel +// (see .gsd/phase/feat-3241-codex-omit-model-by-default/40-design.md "The seam +// decision"). Neither export exists on model-catalog.cjs yet; requiring the +// module does not throw (it just has no such keys today), but calling +// isAnthropicFlavoredModel does — see the new describe block below. +const _modelCatalog = require('../gsd-core/bin/lib/model-catalog.cjs'); +const _modelResolver = require('../gsd-core/bin/lib/model-resolver.cjs'); + +// #2153 follow-up: ensure hooks/dist/ exists before any install integration +// test runs. The Codex install path copies hook files from hooks/dist/, which +// is gitignored and only populated by `npm run build:hooks`. When one of the +// codex-config*.test.cjs files is run in isolation (`node --test +// tests/codex-config-agents.test.cjs`, for example) the build step from the +// npm-test pretest chain does not run, and the "Codex install copies hook +// file" regression silently fails because hooks/dist/ is empty. +// Build on demand so the test passes regardless of runner ordering. +const HOOKS_DIST = path.join(__dirname, '..', 'hooks', 'dist'); +const BUILD_HOOKS_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); +// scripts/build-hooks.js copies pre-built hook files into hooks/dist and +// syntax-checks them with vm — it does not compile/bundle anything. See +// tests/helpers/timeouts.cjs for the class-norm justification. +const { BUILD_TIMEOUT_MS: BUILD_HOOKS_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); +before(() => { + if (!fs.existsSync(HOOKS_DIST) || fs.readdirSync(HOOKS_DIST).length === 0) { + throwIfFailed( + runNode([BUILD_HOOKS_SCRIPT], { timeoutMs: BUILD_HOOKS_TIMEOUT_MS }), + `node ${BUILD_HOOKS_SCRIPT}`, + ); + } +}); + +const { + getCodexSkillAdapterHeader: _getCodexSkillAdapterHeader, + convertClaudeAgentToCodexAgent: _convertClaudeAgentToCodexAgent, + convertClaudeCommandToCodexSkill: _convertClaudeCommandToCodexSkill, + generateCodexAgentToml: _generateCodexAgentToml, + _resetCodexWarningDedupeForTests: __resetCodexWarningDedupeForTests, + cleanupCodexSkillMetadataSidecars, + generateCodexConfigBlock: _generateCodexConfigBlock, + stripGsdFromCodexConfig, + migrateCodexHooksMapFormat: _migrateCodexHooksMapFormat, + mergeCodexConfig: _mergeCodexConfig, + install, + GSD_CODEX_MARKER, + deriveCodexSandboxMode: _deriveCodexSandboxMode, + // #3897 rung 3 (ADR-3473 §8.3, option 2 — HALT.md): anticipated new export + // holding the 17 explicit read-only pins for roles whose tool contract would + // otherwise derive workspace-write (16 measured by HALT.md + gsd-nyquist-auditor, + // surfaced by the list-form parse fix). Does not exist on the current tree — + // destructuring a non-existent key is `undefined`, not a throw, so requiring + // this module still succeeds; every test below that touches it fails on its + // own `typeof` guard instead. + CODEX_SANDBOX_HOLDS: _CODEX_SANDBOX_HOLDS, + parseTomlToObject, + validateCodexConfigSchema: _validateCodexConfigSchema, + uninstall: _uninstall, + CODEX_EXTENDED_HOOK_EVENTS: _CODEX_EXTENDED_HOOK_EVENTS, +} = require('../bin/install.js'); + +const { resolveNodeRunner } = require('../gsd-core/bin/lib/runtime-hooks-surface.cjs'); +const { resolveInstallPlan: _resolveInstallPlan } = require('../gsd-core/bin/lib/runtime-config-adapter-registry.cjs'); +// #3897 fixup: deriveCodexSandboxMode's 2nd param is now the already-resolved +// `tools:` frontmatter VALUE, not raw agent content (codex-agent-toml.cjs no +// longer parses frontmatter at all — no third copy of that extraction). +const { + extractFrontmatterAndBody: _extractFrontmatterAndBody, + extractFrontmatterField: _extractFrontmatterField, +} = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs'); +// #3897 list-form parse fix: the ONE shared `tools:`-value reader both +// sandbox-feeding production paths (`bin/install.js`'s `generateCodexAgentToml` +// and `agent-install-check.cts`'s `checkCodexSandboxPosture`) now route +// through — handles inline (`tools: Read, Write`) AND YAML block-list +// (`tools:` + indented `- Item` lines) form. Used below by `realAgentToolsRaw` +// so the test's own measurement of "what does this role's tool contract +// declare" cannot silently disagree with production (the exact generative- +// fix-divergence shape this fix closes). +const { extractToolsValue: _extractToolsValue } = require('../gsd-core/bin/lib/codex-agent-toml.cjs'); + +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 (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'); +} + +function writeCodexConfig(codexHome, content) { + fs.mkdirSync(codexHome, { recursive: true }); + fs.writeFileSync(path.join(codexHome, 'config.toml'), content, 'utf8'); +} + +function readHooksSessionStartCommands(codexHome) { + const hooksPath = path.join(codexHome, 'hooks.json'); + if (!fs.existsSync(hooksPath)) return []; + const raw = fs.readFileSync(hooksPath, 'utf8').trim(); + if (!raw) return []; + const parsed = JSON.parse(raw); + const table = (parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks)) + ? parsed.hooks + : parsed; + const sessionStart = Array.isArray(table.SessionStart) ? table.SessionStart : []; + return sessionStart.flatMap((entry) => [ + ...(typeof entry?.command === 'string' ? [entry.command] : []), + ...(Array.isArray(entry?.hooks) + ? entry.hooks.map((hook) => hook && hook.command).filter((cmd) => typeof cmd === 'string') + : []), + ]); +} + +function countMatches(content, pattern) { + return (content.match(pattern) || []).length; +} + +function assertNoDraftRootKeys(content) { + assert.ok(!content.includes('model = "gpt-5.6-terra"'), 'does not inject draft model default'); + assert.ok(!content.includes('model_reasoning_effort = "high"'), 'does not inject draft reasoning default'); + assert.ok(!content.includes('disable_response_storage = true'), 'does not inject draft storage default'); +} + +function assertUsesOnlyEol(content, eol) { + if (eol === '\r\n') { + assert.ok(content.includes('\r\n'), 'contains CRLF line endings'); + assert.ok(!content.replace(/\r\r?\n/g, '').includes('\n'), 'does not contain bare LF line endings'); + return; + } + assert.ok(!content.includes('\r\n'), 'does not contain CRLF line endings'); +} + +function assertNoCodexBareGsdToolsInvocation(content, label) { + const patterns = [ + /(^|\r?\n)[ \t]*gsd-tools\s/, + /\$\(\s*gsd-tools\s/, + /`\s*gsd-tools\s/, + /(?:&&|\|\||[;|])\s*gsd-tools\s/, + ]; + for (const pattern of patterns) { + assert.doesNotMatch( + content, + pattern, + `${label} must not contain a command-position bare gsd-tools invocation`, + ); + } +} + +// ─── getCodexSkillAdapterHeader ───────────────────────────────────────────────── + + + + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/issue-2940-codex-config-merge-trailing.test.cjs — consolidation epic #1969 (H3 W4 #3336) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2940-codex-config-merge-trailing (consolidation epic #1969 H3 W4 #3336)", () => { +'use strict'; +process.env.GSD_TEST_MODE = '1'; + +/** + * Regression test for #2940 — `gsd-update` overwrites `~/.codex/config.toml`, + * removing any user/Codex-CLI settings added after the GSD-managed marker block. + * + * Root cause: `mergeCodexConfig`'s Case 2 (marker present) preserved content + * BEFORE the marker but unconditionally discarded everything from the marker to + * EOF, replacing it with a freshly generated GSD block. Since a fresh install + * writes the GSD block as the file's entire content, any settings the user or + * Codex CLI later adds (`[model]`, `[mcp_servers.*]`, `[profiles.*]`) land AFTER + * the block, and every subsequent update wiped them. + * + * The fix preserves genuine trailing TOML by routing the post-marker region + * through the existing `stripLeakedGsdCodexSections` (which removes GSD's own + * managed/leaked sections while keeping user tables), then re-appending it after + * the regenerated GSD block — without regressing #2406's de-dup. + * + * Matrix: .gsd/bug/fix/2940-codex-config-merge-preserves-trailing-content/50-test-matrix.md + * + * NOTE: this describe block covers trailing-content-after-the-marker preservation + * ([model]/[mcp_servers.*]/[profiles.*] appended AFTER the GSD block) — a case the + * pre-existing 'mergeCodexConfig' suite above does not exercise (that suite's cases + * write user content BEFORE the marker/block, not after). Verified non-duplicate + * against both the pre-existing target and the other three folded sources. + */ + +const { describe, test, beforeEach, afterEach } = 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 { cleanup } = require('./helpers.cjs'); + +const { + generateCodexConfigBlock, + mergeCodexConfig, + GSD_CODEX_MARKER, +} = require('../bin/install.js'); + +describe('mergeCodexConfig trailing-content preservation (#2940)', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2940-merge-')); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + /** A GSD block with one agent (the shape installCodexConfig passes). */ + const block = () => + generateCodexConfigBlock([{ name: 'gsd-executor', description: 'Executes plans' }]); + + test('trailingUserModelSectionPreserved', () => { + // Row 1 (failing-first regression): a config with the GSD block FIRST, then a user + // [model] section after it (the real-world layout — fresh install fills the file, + // user settings land after). Re-merge must preserve [model] byte-for-byte. + const configPath = path.join(tmpDir, 'config.toml'); + const trailing = '[model]\nname = "gpt-5.4"\n'; + // First write: GSD block + user content after it (no content before the marker). + fs.writeFileSync(configPath, block() + '\n' + trailing); + + mergeCodexConfig(configPath, block()); + + const content = fs.readFileSync(configPath, 'utf8'); + assert.ok(content.includes('[model]'), 'user [model] section preserved after re-merge'); + assert.ok(content.includes('name = "gpt-5.4"'), 'user model value preserved verbatim'); + assert.ok(content.includes(GSD_CODEX_MARKER), 'GSD marker still present'); + const markerCount = (content.match(new RegExp(escapeRegex(GSD_CODEX_MARKER), 'g')) || []).length; + assert.strictEqual(markerCount, 1, 'exactly one marker (no duplication)'); + assert.ok(content.includes('max_depth ='), 'GSD-managed [agents] block regenerated'); + }); + + test('multipleTrailingTablesPreserved', () => { + // Row 2: multiple trailing user tables ([mcp_servers.*], [profiles.*]). + const configPath = path.join(tmpDir, 'config.toml'); + const trailing = [ + '[mcp_servers.figma]', + 'command = "npx"', + 'args = ["-y", "figma-mcp"]', + '', + '[profiles.dev]', + 'model = "o3"', + 'sandbox_mode = "workspace-write"', + ].join('\n'); + fs.writeFileSync(configPath, block() + '\n' + trailing + '\n'); + + mergeCodexConfig(configPath, block()); + + const content = fs.readFileSync(configPath, 'utf8'); + assert.ok(content.includes('[mcp_servers.figma]'), 'mcp_servers table preserved'); + assert.ok(content.includes('[profiles.dev]'), 'profiles table preserved'); + assert.ok(content.includes('sandbox_mode = "workspace-write"'), 'profile value preserved'); + assert.ok(content.includes(GSD_CODEX_MARKER), 'GSD block regenerated'); + }); + + test('reMergeIsIdempotent', () => { + // Row 3 (acceptance #2): merging the result of a merge again yields identical content. + const configPath = path.join(tmpDir, 'config.toml'); + fs.writeFileSync(configPath, block() + '\n[model]\nname = "o3"\n'); + + mergeCodexConfig(configPath, block()); + const afterFirst = fs.readFileSync(configPath, 'utf8'); + + mergeCodexConfig(configPath, block()); + const afterSecond = fs.readFileSync(configPath, 'utf8'); + + assert.strictEqual(afterSecond, afterFirst, 'second merge is idempotent (no further change)'); + }); + + test('leakedGsdSectionAfterMarkerStillStripped', () => { + // Row 4 (#2406 non-regression): a leaked GSD-managed [agents.gsd-*] section AFTER the + // marker is still REMOVED (not regrown), while genuine user content after it is preserved. + const configPath = path.join(tmpDir, 'config.toml'); + const leakedAndUser = [ + '[agents.gsd-executor]', + 'description = "stale leaked"', + 'config_file = "agents/gsd-executor.toml"', + '', + '[model]', + 'name = "o3"', + ].join('\n'); + fs.writeFileSync(configPath, block() + '\n' + leakedAndUser + '\n'); + + mergeCodexConfig(configPath, block()); + + const content = fs.readFileSync(configPath, 'utf8'); + const gsdStructCount = (content.match(/^\[agents\.gsd-executor\]\s*$/gm) || []).length; + assert.strictEqual(gsdStructCount, 0, 'leaked [agents.gsd-executor] after marker is stripped (not regrown)'); + assert.ok(content.includes('[model]'), 'genuine user [model] after the leaked section still preserved'); + }); + + test('bareAgentsAfterMarkerHandled', () => { + // Row 5: a user AgentsToml scalar (max_threads) the user folded INTO the managed [agents] + // block (the valid, realistic shape — two [agents] tables would be invalid TOML), PLUS a + // separate trailing [model] section. The fix must preserve the user scalar via the existing + // spliceCodexAgentsScalars path AND preserve the trailing [model] via the new trailing-region + // logic, while regenerating exactly one managed [agents] table. + const configPath = path.join(tmpDir, 'config.toml'); + // Simulate: fresh install wrote the GSD block; the user then added max_threads into the + // [agents] table and added a [model] section after it. + const existing = [ + GSD_CODEX_MARKER, + '', + '[agents]', + 'max_depth = 1', + 'max_threads = 4', + '', + '[model]', + 'name = "o3"', + ].join('\n'); + fs.writeFileSync(configPath, existing + '\n'); + + mergeCodexConfig(configPath, block()); + + const content = fs.readFileSync(configPath, 'utf8'); + // The user's max_threads scalar is preserved (spliced into the regenerated managed [agents]); + // there is exactly one [agents] table (the managed one). + assert.ok(content.includes('max_threads = 4'), 'user AgentsToml scalar (max_threads) preserved in managed block'); + const agentsHeaders = (content.match(/^\[agents\]\s*$/gm) || []).length; + assert.strictEqual(agentsHeaders, 1, 'exactly one [agents] table (the managed one)'); + assert.ok(content.includes('max_depth = 1'), 'GSD-managed max_depth still present'); + assert.ok(content.includes('[model]'), 'trailing [model] still preserved'); + }); + + test('beforeAndAfterMarkerBothPreserved', () => { + // Row 6: content both BEFORE and AFTER the marker is preserved; GSD block regenerated once. + const configPath = path.join(tmpDir, 'config.toml'); + const before = '[profiles.work]\nmodel = "gpt-5.4"\n'; + const after = '[mcp_servers.github]\ncommand = "gh-mcp"\n'; + fs.writeFileSync(configPath, before + '\n' + block() + '\n' + after + '\n'); + + mergeCodexConfig(configPath, block()); + + const content = fs.readFileSync(configPath, 'utf8'); + assert.ok(content.includes('[profiles.work]'), 'content before marker preserved'); + assert.ok(content.includes('[mcp_servers.github]'), 'content after marker preserved'); + const markerCount = (content.match(new RegExp(escapeRegex(GSD_CODEX_MARKER), 'g')) || []).length; + assert.strictEqual(markerCount, 1, 'exactly one marker'); + }); + + test('noTrailingContentUnchanged', () => { + // Row 7 (zero-trailing boundary): a config with ONLY the GSD block (fresh-install case) + // re-merges to just the regenerated block — no spurious blank-line artifacts introduced + // by the trailing-preservation logic. + const configPath = path.join(tmpDir, 'config.toml'); + fs.writeFileSync(configPath, block() + '\n'); + + mergeCodexConfig(configPath, block()); + + const content = fs.readFileSync(configPath, 'utf8'); + // No spurious trailing blank lines beyond the single trailing newline. Use a CRLF-safe + // pattern (\r?\n) so the assertion holds under Windows git-autocrlf line endings. + assert.ok(!/(?:\r?\n){3,}$/.test(content), 'no spurious run of blank lines at end of file'); + assert.strictEqual(content.trim(), block().trim(), 'content is exactly the regenerated block (whitespace-trimmed)'); + }); +}); + }); +} + + +// ─── Integration: installCodexConfig ──────────────────────────────────────────── + +describe('installCodexConfig (integration)', () => { + let tmpTarget; + const agentsSrc = path.join(__dirname, '..', 'agents'); + + beforeEach(() => { + tmpTarget = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-codex-install-')); + }); + + afterEach(() => { + cleanup(tmpTarget); + }); + + // Only run if agents/ directory exists (not in CI without full checkout) + const hasAgents = fs.existsSync(agentsSrc); + + (hasAgents ? test : test.skip)('generates config.toml and agent .toml files', () => { + const { installCodexConfig } = require('../bin/install.js'); + const count = installCodexConfig(tmpTarget, agentsSrc); + + assert.ok(count >= 11, `installed ${count} agents (expected >= 11)`); + + // Verify config.toml + const configPath = path.join(tmpTarget, 'config.toml'); + assert.ok(fs.existsSync(configPath), 'config.toml exists'); + const config = fs.readFileSync(configPath, 'utf8'); + assert.ok(config.includes(GSD_CODEX_MARKER), 'has GSD marker'); + // #2406: config.toml must NOT register agent roles — the standalone + // agents/.toml (verified below) is the sole canonical source + // Codex auto-discovers. A role table here would be a second, + // duplicate registration of the same role. + assert.ok(!config.includes('[agents.gsd-executor]'), 'no executor role table in config.toml'); + assert.strictEqual((config.match(/^\[agents\.gsd-/gm) || []).length, 0, 'zero [agents.gsd-*] role tables of any kind'); + assert.strictEqual((config.match(/^config_file = /gm) || []).length, 0, 'zero config_file lines'); + assert.ok(!config.includes('multi_agent'), 'no feature flags'); + + // Verify per-agent .toml files + const agentsDir = path.join(tmpTarget, 'agents'); + assert.ok(fs.existsSync(path.join(agentsDir, 'gsd-executor.toml')), 'executor .toml exists'); + assert.ok(fs.existsSync(path.join(agentsDir, 'gsd-plan-checker.toml')), 'plan-checker .toml exists'); + + const executorToml = fs.readFileSync(path.join(agentsDir, 'gsd-executor.toml'), 'utf8'); + assert.ok(executorToml.includes('name = "gsd-executor"'), 'executor has name'); + assert.ok(executorToml.includes('description = "Executes GSD plans with atomic commits, deviation handling, checkpoint protocols, and state management. Spawned by execute-phase orchestrator or execute-plan command."'), 'executor has description'); + assert.ok(executorToml.includes('sandbox_mode = "workspace-write"'), 'executor is workspace-write'); + assert.ok(executorToml.includes('developer_instructions'), 'has developer_instructions'); + + const checkerToml = fs.readFileSync(path.join(agentsDir, 'gsd-plan-checker.toml'), 'utf8'); + assert.ok(checkerToml.includes('name = "gsd-plan-checker"'), 'plan-checker has name'); + assert.ok(checkerToml.includes('sandbox_mode = "read-only"'), 'plan-checker is read-only'); + }); + + // PATHS-01: no ~/.claude references should leak into generated .toml files (#2320) + // Covers both trailing-slash and bare end-of-string forms, and scans all .toml + // files (agents/ subdirectory + top-level config.toml if present). + (hasAgents ? test : test.skip)('generated .toml files contain no leaked ~/.claude paths (PATHS-01)', () => { + const { installCodexConfig } = require('../bin/install.js'); + installCodexConfig(tmpTarget, agentsSrc); + + // Collect all .toml files: per-agent files in agents/ plus top-level config.toml. + // Not the shared listAgentFiles() helper: reads the INSTALLED target dir and + // collects generated .toml (absolute paths), not the source .md roster. + const agentsDir = path.join(tmpTarget, 'agents'); + const tomlFiles = fs.readdirSync(agentsDir) + .filter(f => f.endsWith('.toml')) + .map(f => path.join(agentsDir, f)); + const topLevel = path.join(tmpTarget, 'config.toml'); + if (fs.existsSync(topLevel)) tomlFiles.push(topLevel); + assert.ok(tomlFiles.length > 0, 'at least one .toml file generated'); + + // Match ~/.claude, $HOME/.claude, or ./.claude with or without trailing slash + const leakPattern = /(?:~|\$HOME|\.)\/\.claude(?:\/|$)/; + const leaks = []; + for (const filePath of tomlFiles) { + const content = fs.readFileSync(filePath, 'utf8'); + if (leakPattern.test(content)) { + leaks.push(path.relative(tmpTarget, filePath)); + } + } + assert.deepStrictEqual(leaks, [], `No .toml files should contain .claude paths; found leaks in: ${leaks.join(', ')}`); + }); + + (hasAgents ? test : test.skip)('generated Codex agent .toml files do not call bare gsd-tools', () => { + const { installCodexConfig } = require('../bin/install.js'); + installCodexConfig(tmpTarget, agentsSrc); + + // Not the shared listAgentFiles() helper: reads the INSTALLED target dir and + // filters generated gsd-*.toml output, not the source .md roster. + const agentsDir = path.join(tmpTarget, 'agents'); + const tomlFiles = fs.readdirSync(agentsDir) + .filter((file) => file.startsWith('gsd-') && file.endsWith('.toml')); + assert.ok(tomlFiles.length > 0, 'expected generated Codex agent toml files'); + + for (const file of tomlFiles) { + const content = fs.readFileSync(path.join(agentsDir, file), 'utf8'); + assertNoCodexBareGsdToolsInvocation(content, `agents/${file}`); + } + }); +}); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/issue-2834-codex-install-model-ordering.test.cjs — consolidation epic #1969 (H3 W4 #3336) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2834-codex-install-model-ordering (consolidation epic #1969 H3 W4 #3336)", () => { +// allow-test-rule: structural-implementation-guard (#2834) +'use strict'; + +// Regression guard for #2834: on a clean Codex install, agent TOMLs contained no +// model-routing fields because defaults.json (resolve_model_ids + runtime) was written +// AFTER installCodexConfig generated the TOMLs. The fix extracts writeNonClaudeDefaults +// and calls it BEFORE installCodexConfig. This test asserts the ordering invariant in +// the install source so a future edit can't silently re-introduce the gap. +// +// Verified non-duplicate: no existing coverage in this file asserts on +// writeNonClaudeDefaults / the install-flow call ordering (source-text guard), and +// none of the other three folded sources touch this. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const INSTALL_JS = path.join(__dirname, '..', 'bin', 'install.js'); + +test('writeNonClaudeDefaults is called before installCodexConfig in the Codex install flow (#2834)', () => { + const src = fs.readFileSync(INSTALL_JS, 'utf8'); + + // Find the call to writeNonClaudeDefaults that precedes installCodexConfig. + const writeIdx = src.indexOf('writeNonClaudeDefaults(runtime);'); // allow-test-rule: structural-implementation-guard (#2834) + assert.ok(writeIdx !== -1, 'writeNonClaudeDefaults(runtime) must be called in the install flow'); + + // Find the FIRST installCodexConfig call AFTER the writeNonClaudeDefaults call. + const codexGenIdx = src.indexOf('installCodexConfig(targetDir', writeIdx); // allow-test-rule: structural-implementation-guard (#2834) + assert.ok(codexGenIdx !== -1 && codexGenIdx > writeIdx, + 'installCodexConfig must be called AFTER writeNonClaudeDefaults so defaults.json ' + + '(resolve_model_ids + runtime) exists before agent TOML generation reads it (#2834)'); + + // The #2834 comment must be present at the call site. + const callSite = src.slice(writeIdx - 300, writeIdx + 100); + assert.ok(/#2834/.test(callSite), 'the writeNonClaudeDefaults call must carry the #2834 rationale comment'); // allow-test-rule: structural-implementation-guard (#2834) +}); + +test('writeNonClaudeDefaults function exists and is a no-op for Claude (#2834)', () => { + const src = fs.readFileSync(INSTALL_JS, 'utf8'); + const fnIdx = src.indexOf('function writeNonClaudeDefaults('); // allow-test-rule: structural-implementation-guard (#2834) + assert.ok(fnIdx !== -1, 'writeNonClaudeDefaults must be defined as a function'); + // Bound the slice by the next top-level declaration rather than a fixed + // character count, so adding a comment or a guard inside the function cannot + // push the asserted tokens out of the window and red this test spuriously. + const nextFnIdx = src.indexOf('\nfunction ', fnIdx + 1); // allow-test-rule: structural-implementation-guard (#2834) + const fnBody = src.slice(fnIdx, nextFnIdx === -1 ? undefined : nextFnIdx); + // Source-text guard, not a behavioral call: writeNonClaudeDefaults() early-returns + // as a no-op whenever process.env.GSD_TEST_MODE is set (see its own body), and this + // suite sets GSD_TEST_MODE='1' file-wide (line 14), so invoking it here could never + // observe the resolve_model_ids/runtime writes it is supposed to make (#2834). + assert.ok(/nativeModelAliases/.test(fnBody), 'writeNonClaudeDefaults must early-return for Claude (nativeModelAliases check)'); // allow-test-rule: structural-implementation-guard (#2834) + assert.ok(/resolve_model_ids/.test(fnBody), 'writeNonClaudeDefaults must write resolve_model_ids'); // allow-test-rule: structural-implementation-guard (#2834) + assert.ok(/defaults\.runtime/.test(fnBody), 'writeNonClaudeDefaults must write runtime'); // allow-test-rule: structural-implementation-guard (#2834) +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/issue-2639-codex-toml-neutralization.test.cjs — consolidation epic #1969 (H3 W4 #3336) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2639-codex-toml-neutralization (consolidation epic #1969 H3 W4 #3336)", () => { +/** + * Regression: issue #2639 — Codex install generated agent TOMLs with stale + * Claude-specific references (CLAUDE.md, .claude/skills/, .claudeignore). + * + * RCA: `installCodexConfig()` applied a narrow path-only regex pass before + * calling `generateCodexAgentToml()`, bypassing the full + * `convertClaudeToCodexMarkdown()` + `neutralizeAgentReferences(..., 'AGENTS.md')` + * pipeline used on the .md emit path. Fix routes the TOML path through the + * same pipeline and extends the pipeline to cover bare `.claude/skills/`, + * `.claude/commands/`, `.claude/agents/`, and `.claudeignore`. + * + * Verified non-duplicate: the pre-existing 'generateCodexAgentToml' suite covers + * model_overrides/sandbox_mode/reasoning-effort, not CLAUDE.md/.claudeignore/skills-path + * neutralization in the emitted TOML; the '#570 — Codex leak scanner sub-bugs' suite + * covers ~/.claude path leaks via convertClaudeToCodexMarkdown but not the + * installCodexConfig()-level TOML-emit pipeline this regression targets. + */ + +process.env.GSD_TEST_MODE = '1'; + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const { installCodexConfig } = require('../bin/install.js'); +const { cleanup } = require('./helpers.cjs'); + +function makeTempDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2639-')); +} + +function writeAgentFixture(agentsSrc, name, body) { + const content = `--- +name: ${name} +description: Test agent for #2639 +--- + +${body} +`; + fs.writeFileSync(path.join(agentsSrc, `${name}.md`), content); +} + +describe('#2639 — Codex TOML emit routes through full neutralization pipeline', () => { + let tmpDir; + let agentsSrc; + let targetDir; + + beforeEach(() => { + tmpDir = makeTempDir(); + agentsSrc = path.join(tmpDir, 'agents'); + targetDir = path.join(tmpDir, 'codex'); + fs.mkdirSync(agentsSrc, { recursive: true }); + fs.mkdirSync(targetDir, { recursive: true }); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('strips CLAUDE.md, .claude/skills/, .claude/commands/, .claude/agents/, and .claudeignore from emitted TOML', () => { + writeAgentFixture(agentsSrc, 'gsd-code-reviewer', [ + '**Project instructions:** Read `./CLAUDE.md` if it exists.', + '', + '**CLAUDE.md enforcement:** If `./CLAUDE.md` exists, treat it as hard constraints.', + '', + '**Project skills:** Check `.claude/skills/` or `.agents/skills/` directory.', + '', + 'Also check `.claude/commands/` and `.claude/agents/` for definitions.', + '', + 'DO respect .gitignore and .claudeignore. Do not review ignored files.', + '', + 'Claude will refuse the task if policy violated.', + ].join('\n')); + + installCodexConfig(targetDir, agentsSrc); + + const tomlPath = path.join(targetDir, 'agents', 'gsd-code-reviewer.toml'); + assert.ok(fs.existsSync(tomlPath), 'per-agent TOML written'); + const toml = fs.readFileSync(tomlPath, 'utf8'); + + assert.ok(!toml.includes('CLAUDE.md'), 'no CLAUDE.md references remain in TOML'); + assert.ok(!toml.includes('.claude/skills/'), 'no .claude/skills/ references remain'); + assert.ok(!toml.includes('.claude/commands/'), 'no .claude/commands/ references remain'); + assert.ok(!toml.includes('.claude/agents/'), 'no .claude/agents/ references remain'); + assert.ok(!toml.includes('.claudeignore'), 'no .claudeignore references remain'); + + assert.ok(toml.includes('AGENTS.md'), 'AGENTS.md substituted for CLAUDE.md'); + assert.ok( + toml.includes('.codex/skills/') || toml.includes('.agents/skills/'), + 'skills path neutralized' + ); + + // Standalone "Claude" agent-name references replaced + assert.ok(!/\bClaude\b(?! Code| Opus| Sonnet| Haiku| native| based)/.test(toml), + 'standalone Claude agent-name references replaced'); + }); + + test('preserves Claude product/model names (Claude Code, Claude Opus) in TOML', () => { + writeAgentFixture(agentsSrc, 'gsd-executor', [ + 'This agent runs under Claude Code with the Claude Opus 4 model.', + 'Do not confuse with Claude Sonnet or Claude Haiku.', + ].join('\n')); + + installCodexConfig(targetDir, agentsSrc); + const toml = fs.readFileSync(path.join(targetDir, 'agents', 'gsd-executor.toml'), 'utf8'); + + assert.ok(toml.includes('Claude Code'), 'Claude Code product name preserved'); + assert.ok(toml.includes('Claude Opus'), 'Claude Opus model name preserved'); + }); +}); + }); +} + + +// ─── Codex config.toml [features] safety (#1202) ───────────────────────────── + +describe('codex features section safety', () => { + test('non-boolean keys under [features] are moved to top level', () => { + // Simulate the bug from #1202: model = "gpt-5.4" under [features] + // causes "invalid type: string, expected a boolean in features" + const configContent = `[features]\ncodex_hooks = true\n\nmodel = "gpt-5.4"\nmodel_reasoning_effort = "medium"\n\n[agents.gsd-executor]\ndescription = "test"\n`; + + const featuresMatch = configContent.match(/\[features\]\r?\n([\s\S]*?)(?=\n\[|$)/); + assert.ok(featuresMatch, 'features section found'); + + const featuresBody = featuresMatch[1]; + const nonBooleanKeys = featuresBody.split(/\r?\n/) + .filter(line => line.match(/^\s*\w+\s*=/) && !line.match(/=\s*(true|false)\s*(#.*)?$/)) + .map(line => line.trim()); + + assert.strictEqual(nonBooleanKeys.length, 2, 'should detect 2 non-boolean keys'); + assert.ok(nonBooleanKeys.includes('model = "gpt-5.4"'), 'detects model key'); + assert.ok(nonBooleanKeys.includes('model_reasoning_effort = "medium"'), 'detects model_reasoning_effort key'); + }); + + test('boolean keys under [features] are NOT flagged', () => { + const configContent = `[features]\ncodex_hooks = true\nmulti_agent = false\n`; + + const featuresMatch = configContent.match(/\[features\]\r?\n([\s\S]*?)(?=\n\[|$)/); + const featuresBody = featuresMatch[1]; + const nonBooleanKeys = featuresBody.split(/\r?\n/) + .filter(line => line.match(/^\s*\w+\s*=/) && !line.match(/=\s*(true|false)\s*(#.*)?$/)) + .map(line => line.trim()); + + assert.strictEqual(nonBooleanKeys.length, 0, 'no non-boolean keys in a clean config'); + }); +}); + +describe('Codex install hook configuration (e2e)', () => { + let tmpDir; + let codexHome; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-codex-e2e-')); + codexHome = path.join(tmpDir, 'codex-home'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('Codex install copies hook file that is referenced in hooks.json (#2153)', () => { + // Regression test: Codex install writes gsd-check-update hook reference into + // hooks.json and must also copy the hook file to ~/$CODEX_HOME/hooks/ + runCodexInstall(codexHome); + + const configContent = readCodexConfig(codexHome); + const parsedConfig = parseTomlToObject(configContent); + assert.ok( + !parsedConfig.hooks || !Array.isArray(parsedConfig.hooks.SessionStart), + 'config.toml does not carry managed SessionStart hooks' + ); + const hooksJsonCommands = readHooksSessionStartCommands(codexHome); + assert.equal( + hooksJsonCommands.some((cmd) => cmd.includes('gsd-check-update')), + true, + 'hooks.json references gsd-check-update (.js on POSIX, .cmd on Windows)' + ); + // The hook file must physically exist at the referenced path + const hookFile = path.join(codexHome, 'hooks', 'gsd-check-update.js'); + assert.ok( + fs.existsSync(hookFile), + `gsd-check-update.js must exist at ${hookFile} — hooks.json references it (directly on POSIX, via .cmd shim on Windows) but file was not installed` + ); + }); + + test('fresh CODEX_HOME enables codex_hooks without draft root defaults', () => { + runCodexInstall(codexHome); + + const content = readCodexConfig(codexHome); + assert.ok(content.includes('[features]\nhooks = true\n'), 'writes codex_hooks feature'); + const parsed = parseTomlToObject(content); + assert.ok(!parsed.hooks || !Array.isArray(parsed.hooks.SessionStart), 'config.toml does not carry managed SessionStart hooks'); + // #3017 / #3426: on POSIX the handler command uses the absolute Node binary path + // "" "" + // On Windows (#3426) a .cmd shim is written instead; the command in hooks.json + // is the quoted .cmd path (no node runner prefix — cmd.exe executes .cmd natively). + const hooksJsonCommands = readHooksSessionStartCommands(codexHome); + const gsdCommands = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update')); + assert.strictEqual(gsdCommands.length, 1, 'writes one GSD update hook in hooks.json'); + if (process.platform === 'win32') { + // On Windows, the command is the .cmd shim path (quoted). + const expectedCmdPath = path.join(codexHome, 'hooks', 'gsd-check-update.cmd').replace(/\\/g, '/'); + assert.strictEqual(gsdCommands[0], JSON.stringify(expectedCmdPath), 'win32: handler command must be the .cmd shim path (#3426)'); + } else { + // On POSIX, the command is the node runner + .js hook path. + const expectedRunner = JSON.parse(resolveNodeRunner()); + const expectedHookPath = path.join(codexHome, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/'); + const expectedCommand = `"${expectedRunner}" "${expectedHookPath}"`; + assert.strictEqual(gsdCommands[0], expectedCommand, 'handler command must use absolute node runner pointing at gsd-check-update.js (#3017)'); + } + assert.strictEqual(countMatches(content, /^hooks = true$/gm), 1, 'writes one codex_hooks key'); + assertNoDraftRootKeys(content); + assertUsesOnlyEol(content, '\n'); + }); + + test('#2406: config.toml carries no config_file entries — standalone agents/*.toml under CODEX_HOME are the sole canonical source', () => { + runCodexInstall(codexHome); + + const content = readCodexConfig(codexHome); + // config.toml previously carried a `config_file = "/.toml"` + // line per role, pointing back at the standalone TOML Codex already + // auto-discovers under $CODEX_HOME/agents/ — a second, duplicate + // registration of the same role that produced one + // "Ignoring malformed agent role definition: duplicate agent role name" + // warning per agent. That line is gone entirely now. + const configFileLines = content.split(/\r?\n/).filter(l => l.startsWith('config_file = ')); + assert.deepStrictEqual(configFileLines, [], 'config.toml has zero config_file entries'); + + // The standalone per-agent TOMLs are still written under CODEX_HOME/agents/ + // and are what Codex auto-discovers. + const agentsDir = path.join(codexHome, 'agents'); + const tomlFiles = fs.existsSync(agentsDir) + ? fs.readdirSync(agentsDir).filter((f) => f.startsWith('gsd-') && f.endsWith('.toml')) + : []; + assert.ok(tomlFiles.length > 0, 'standalone gsd-*.toml files exist under CODEX_HOME/agents/'); + }); + + test('re-install repairs non-boolean keys trapped under [features] by previous install (#1379)', () => { + // Bug: a pre-#1346 install prepended [features] before bare top-level keys, + // trapping model= under [features]. Re-installing with the fix must detect + // and relocate those keys back to the top level so Codex can parse them. + writeCodexConfig(codexHome, [ + '[features]', + 'codex_hooks = true', + '', + 'model = "gpt-5.3-codex"', + 'model_reasoning_effort = "high"', + '', + '[projects."/Users/oltmannk/myproject"]', + 'trust_level = "trusted"', + '', + ].join('\n')); + + runCodexInstall(codexHome); + + const content = readCodexConfig(codexHome); + + // model= and model_reasoning_effort= must NOT be under [features] + const featuresIndex = content.indexOf('[features]'); + const modelIndex = content.indexOf('model = "gpt-5.3-codex"'); + const reasoningIndex = content.indexOf('model_reasoning_effort = "high"'); + assert.ok(modelIndex !== -1, 'model key is present'); + assert.ok(reasoningIndex !== -1, 'model_reasoning_effort key is present'); + assert.ok(modelIndex < featuresIndex, 'model= relocated before [features]'); + assert.ok(reasoningIndex < featuresIndex, 'model_reasoning_effort= relocated before [features]'); + + // [features] should only contain boolean keys + const featuresMatch = content.match(/\[features\]\r?\n([\s\S]*?)(?=\n\[|$)/); + assert.ok(featuresMatch, 'features section found'); + const featuresBody = featuresMatch[1]; + const nonBooleanKeys = featuresBody.split(/\r?\n/) + .filter(line => line.match(/^\s*\w+\s*=/) && !line.match(/=\s*(true|false)\s*(#.*)?$/)); + assert.strictEqual(nonBooleanKeys.length, 0, 'no non-boolean keys under [features]'); + + // User content preserved + assert.ok(content.includes('[projects."/Users/oltmannk/myproject"]'), 'preserves project section'); + assert.ok(content.includes('trust_level = "trusted"'), 'preserves project trust level'); + assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 1, 'one codex_hooks key'); + }); + + test('existing LF config without [features] gets one features block and preserves user content', () => { + writeCodexConfig(codexHome, [ + '# user comment', + '[model]', + 'name = "o3"', + '', + '[[hooks]]', + 'event = "SessionStart"', + 'command = "echo custom"', + '', + ].join('\n')); + + runCodexInstall(codexHome); + + const content = readCodexConfig(codexHome); + assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'creates one [features] section'); + assert.strictEqual(countMatches(content, /^hooks = true$/gm), 1, 'creates one codex_hooks key'); + assert.ok(content.includes('# user comment'), 'preserves user comment'); + assert.ok(content.includes('[model]\nname = "o3"'), 'preserves model section'); + assert.ok(content.includes('command = "echo custom"'), 'preserves custom hook'); + const hooksJsonCommands = readHooksSessionStartCommands(codexHome); + const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update')); + assert.strictEqual(gsdEntries.length, 1, 'adds one GSD update hook in hooks.json'); + assertNoDraftRootKeys(content); + }); + + test('bare top-level keys are NOT trapped under [features] (#1202)', () => { + // Real-world config: model= and model_reasoning_effort= at root level, + // followed by [projects] section. GSD must not prepend [features] before + // these keys, which would make Codex reject them as "expected a boolean". + writeCodexConfig(codexHome, [ + 'model = "gpt-5.4"', + 'model_reasoning_effort = "high"', + '', + '[projects."/home/user/myproject"]', + 'trust_level = "trusted"', + '', + ].join('\n')); + + runCodexInstall(codexHome); + + const content = readCodexConfig(codexHome); + + // [features] must come AFTER bare top-level keys + const featuresIndex = content.indexOf('[features]'); + const modelIndex = content.indexOf('model = "gpt-5.4"'); + const reasoningIndex = content.indexOf('model_reasoning_effort = "high"'); + assert.ok(modelIndex < featuresIndex, 'model= stays before [features]'); + assert.ok(reasoningIndex < featuresIndex, 'model_reasoning_effort= stays before [features]'); + + // [features] should only contain boolean keys + const featuresMatch = content.match(/\[features\]\r?\n([\s\S]*?)(?=\n\[|$)/); + assert.ok(featuresMatch, 'features section found'); + const featuresBody = featuresMatch[1]; + const nonBooleanKeys = featuresBody.split(/\r?\n/) + .filter(line => line.match(/^\s*\w+\s*=/) && !line.match(/=\s*(true|false)\s*(#.*)?$/)); + assert.strictEqual(nonBooleanKeys.length, 0, 'no non-boolean keys under [features]'); + + // User content preserved + assert.ok(content.includes('[projects."/home/user/myproject"]'), 'preserves project section'); + assert.ok(content.includes('trust_level = "trusted"'), 'preserves project trust level'); + }); + + test('existing CRLF config without [features] preserves CRLF and adds codex_hooks', () => { + writeCodexConfig(codexHome, '# user comment\r\n[model]\r\nname = "o3"\r\n'); + + runCodexInstall(codexHome); + + const content = readCodexConfig(codexHome); + assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'creates one [features] section'); + assert.strictEqual(countMatches(content, /^hooks = true$/gm), 1, 'creates one codex_hooks key'); + assert.ok(content.includes('# user comment'), 'preserves user comment'); + assert.ok(content.includes('[model]\r\nname = "o3"'), 'preserves model section'); + // [features] should be inserted between top-level lines and [model], not prepended + const featuresIndex = content.indexOf('[features]'); + const modelIndex = content.indexOf('[model]'); + assert.ok(featuresIndex < modelIndex, '[features] comes before [model]'); + assertUsesOnlyEol(content, '\r\n'); + assertNoDraftRootKeys(content); + }); + + test('existing CRLF [features] comment-only table gets codex_hooks without losing adjacent text', () => { + writeCodexConfig(codexHome, [ + '# user comment', + '[features]', + '# keep me', + '', + '[model]', + 'name = "o3"', + '', + ].join('\r\n')); + + runCodexInstall(codexHome); + + const content = readCodexConfig(codexHome); + assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'keeps one [features] section'); + assert.strictEqual(countMatches(content, /^hooks = true$/gm), 1, 'adds one codex_hooks key'); + assert.ok(content.includes('[features]\r\n# keep me\r\n\r\nhooks = true\r\n'), 'adds codex_hooks within comment-only table'); + assert.ok(content.includes('[model]\r\nname = "o3"\r\n'), 'preserves following table'); + assertUsesOnlyEol(content, '\r\n'); + assertNoDraftRootKeys(content); + }); + + test('existing [features] with trailing comment gets one codex_hooks without a second table', () => { + writeCodexConfig(codexHome, [ + '[features] # keep comment', + 'other_feature = true', + '', + '[model]', + 'name = "o3"', + '', + ].join('\n')); + + runCodexInstall(codexHome); + + const content = readCodexConfig(codexHome); + assert.strictEqual(countMatches(content, /^\s*\[features\](?:\s*#.*)?$/gm), 1, 'keeps one commented [features] header'); + assert.strictEqual(countMatches(content, /^hooks = true$/gm), 1, 'adds one codex_hooks key'); + assert.ok(content.includes('[features] # keep comment\nother_feature = true'), 'preserves commented features table'); + assert.ok(content.indexOf('hooks = true') > content.indexOf('[features] # keep comment'), 'adds codex_hooks within existing features table'); + assert.ok(content.indexOf('hooks = true') < content.indexOf('[model]'), 'does not create a second features table before model'); + assertNoDraftRootKeys(content); + }); + + test('existing [features] at EOF without trailing newline is updated in place', () => { + writeCodexConfig(codexHome, '[model]\nname = "o3"\n\n[features]'); + + runCodexInstall(codexHome); + + const content = readCodexConfig(codexHome); + assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'keeps one [features] section'); + assert.strictEqual(countMatches(content, /^hooks = true$/gm), 1, 'adds one codex_hooks key'); + assert.ok(content.indexOf('hooks = true') > content.indexOf('[features]'), 'adds codex_hooks after the existing EOF features header'); + // In this EOF-without-trailing-newline edge case, the pre-existing + // [features] header has no blank-line boundary to close it, so the + // appended GSD marker/ownership comment textually falls *inside* what + // reads as the [features] section body, and `hooks = true` is inserted + // at the end of that body — after the marker, not before it. That + // ordering is unrelated to #2406 (verified unchanged against + // origin/next's install.js) and #2406 removed the [agents.] role + // tables that used to anchor this assertion, so anchor on the bare + // [agents] dispatch-tuning table instead — codex_hooks always lands + // before it. + assert.ok(content.indexOf('hooks = true') < content.indexOf('[agents]'), 'keeps codex_hooks before the [agents] dispatch-tuning table'); + assertNoDraftRootKeys(content); + }); + + test('existing empty [features] and codex_hooks = false are normalized and remain idempotent', () => { + writeCodexConfig(codexHome, [ + '[features]', + 'codex_hooks = false', + 'other_feature = true', + '', + '[[hooks]]', + 'event = "SessionStart"', + 'command = "echo custom"', + '', + ].join('\n')); + + runCodexInstall(codexHome); + runCodexInstall(codexHome); + runCodexInstall(codexHome); + + const content = readCodexConfig(codexHome); + assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'keeps one [features] section'); + assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 1, 'normalizes to one codex_hooks = true'); + assert.ok(!content.includes('codex_hooks = false'), 'removes false codex_hooks value'); + assert.ok(content.includes('other_feature = true'), 'preserves other feature keys'); + assert.ok(content.includes('command = "echo custom"'), 'preserves custom hook'); + const hooksJsonCommands = readHooksSessionStartCommands(codexHome); + const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update')); + assert.strictEqual(gsdEntries.length, 1, 'does not duplicate GSD update hook in hooks.json'); + assertNoDraftRootKeys(content); + }); + + test('quoted codex_hooks keys inside [features] are normalized without adding a bare duplicate', () => { + writeCodexConfig(codexHome, [ + '[features]', + '"codex_hooks" = false', + 'other_feature = true', + '', + ].join('\n')); + + runCodexInstall(codexHome); + runCodexInstall(codexHome); + + const content = readCodexConfig(codexHome); + assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'keeps one [features] section'); + assert.strictEqual(countMatches(content, /^"codex_hooks" = true$/gm), 1, 'normalizes the quoted key to true'); + assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 0, 'does not append a bare duplicate codex_hooks key'); + assert.ok(content.includes('other_feature = true'), 'preserves other feature keys'); + assertNoDraftRootKeys(content); + }); + + test('quoted [features] headers are recognized as the existing features table', () => { + writeCodexConfig(codexHome, [ + '["features"]', + '"codex_hooks" = false', + 'other_feature = true', + '', + '[model]', + 'name = "o3"', + '', + ].join('\n')); + + runCodexInstall(codexHome); + runCodexInstall(codexHome); + + const content = readCodexConfig(codexHome); + assert.strictEqual(countMatches(content, /^\[(?:"features"|'features'|features)\]\s*$/gm), 1, 'keeps one features table'); + assert.strictEqual(countMatches(content, /^"codex_hooks" = true$/gm), 1, 'normalizes the quoted codex_hooks key to true'); + assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 0, 'does not prepend a second bare features table'); + assert.ok(content.includes('other_feature = true'), 'preserves existing feature keys'); + const hooksJsonCommands = readHooksSessionStartCommands(codexHome); + const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update')); + assert.strictEqual(gsdEntries.length, 1, 'keeps one GSD update hook in hooks.json'); + assertNoDraftRootKeys(content); + }); + + test('quoted table headers containing # are parsed without treating # as a comment start', () => { + writeCodexConfig(codexHome, [ + '[features."a#b"]', + 'enabled = true', + '', + '[model]', + 'name = "o3"', + '', + ].join('\n')); + + runCodexInstall(codexHome); + runCodexInstall(codexHome); + + const content = readCodexConfig(codexHome); + assert.ok(content.includes('[features."a#b"]\nenabled = true'), 'preserves the quoted nested features table'); + assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'adds one real top-level features table'); + assert.strictEqual(countMatches(content, /^hooks = true$/gm), 1, 'adds one codex_hooks key'); + const hooksJsonCommands = readHooksSessionStartCommands(codexHome); + const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update')); + assert.strictEqual(gsdEntries.length, 1, 'remains idempotent for the GSD hook block in hooks.json'); + assertNoDraftRootKeys(content); + }); + + test('existing dotted features config stays dotted and does not grow a [features] table', () => { + writeCodexConfig(codexHome, [ + 'features.other_feature = true', + '', + '[model]', + 'name = "o3"', + '', + ].join('\n')); + + runCodexInstall(codexHome); + runCodexInstall(codexHome); + + const content = readCodexConfig(codexHome); + assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 0, 'does not add a [features] table'); + assert.strictEqual(countMatches(content, /^features\.hooks = true$/gm), 1, 'adds one dotted codex_hooks key'); + assert.ok(content.includes('features.other_feature = true'), 'preserves existing dotted features key'); + const hooksJsonCommands = readHooksSessionStartCommands(codexHome); + const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update')); + assert.strictEqual(gsdEntries.length, 1, 'adds one GSD update hook for dotted codex_hooks and remains idempotent'); + assertNoDraftRootKeys(content); + }); + + test('root inline-table features assignments are left untouched without appending invalid dotted keys or hooks', () => { + writeCodexConfig(codexHome, [ + 'features = { other_feature = true }', + '', + '[model]', + 'name = "o3"', + '', + ].join('\n')); + + runCodexInstall(codexHome); + runCodexInstall(codexHome); + + const content = readCodexConfig(codexHome); + assert.ok(content.includes('features = { other_feature = true }'), 'preserves the root inline-table assignment'); + assert.strictEqual(countMatches(content, /^features\.codex_hooks = true$/gm), 0, 'does not append an invalid dotted codex_hooks key'); + assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 0, 'does not prepend a features table'); + assert.strictEqual(countMatches(content, /gsd-check-update\.js/g), 0, 'does not add the GSD hook block when codex_hooks cannot be enabled safely'); + // #2406: config.toml no longer carries an [agents.] role table — + // it still installs the managed [agents] dispatch-tuning block. + assert.ok(content.includes(GSD_CODEX_MARKER), 'still installs the managed GSD block'); + assert.ok(!content.includes('[agents.gsd-executor]'), 'no agent role table (canonical source is the standalone TOML)'); + assertNoDraftRootKeys(content); + }); + + test('root scalar features assignments are left untouched without appending invalid dotted keys or hooks', () => { + writeCodexConfig(codexHome, [ + 'features = "disabled"', + '', + '[model]', + 'name = "o3"', + '', + ].join('\n')); + + runCodexInstall(codexHome); + runCodexInstall(codexHome); + + const content = readCodexConfig(codexHome); + assert.ok(content.includes('features = "disabled"'), 'preserves the root scalar assignment'); + assert.strictEqual(countMatches(content, /^features\.codex_hooks = true$/gm), 0, 'does not append an invalid dotted codex_hooks key'); + assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 0, 'does not prepend a features table'); + assert.strictEqual(countMatches(content, /gsd-check-update\.js/g), 0, 'does not add the GSD hook block when codex_hooks cannot be enabled safely'); + // #2406: config.toml no longer carries an [agents.] role table — + // it still installs the managed [agents] dispatch-tuning block. + assert.ok(content.includes(GSD_CODEX_MARKER), 'still installs the managed GSD block'); + assert.ok(!content.includes('[agents.gsd-executor]'), 'no agent role table (canonical source is the standalone TOML)'); + assertNoDraftRootKeys(content); + }); + + test('quoted dotted codex_hooks keys stay dotted and are normalized without duplication', () => { + writeCodexConfig(codexHome, [ + 'features."codex_hooks" = false', + 'features.other_feature = true', + '', + '[model]', + 'name = "o3"', + '', + ].join('\n')); + + runCodexInstall(codexHome); + runCodexInstall(codexHome); + + const content = readCodexConfig(codexHome); + assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 0, 'does not add a [features] table'); + assert.strictEqual(countMatches(content, /^features\."codex_hooks" = true$/gm), 1, 'normalizes the quoted dotted key to true'); + assert.strictEqual(countMatches(content, /^features\.codex_hooks = true$/gm), 0, 'does not append a bare dotted duplicate'); + assert.ok(content.includes('features.other_feature = true'), 'preserves other dotted features keys'); + const hooksJsonCommands = readHooksSessionStartCommands(codexHome); + const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update')); + assert.strictEqual(gsdEntries.length, 1, 'adds one GSD update hook for quoted dotted codex_hooks and remains idempotent'); + assertNoDraftRootKeys(content); + }); + + test('multiline dotted features assignments insert codex_hooks after the full assignment block', () => { + writeCodexConfig(codexHome, [ + 'features.notes = """', + 'keep-me', + '"""', + '', + '[model]', + 'name = "o3"', + '', + ].join('\n')); + + runCodexInstall(codexHome); + runCodexInstall(codexHome); + + const content = readCodexConfig(codexHome); + assert.ok(content.includes('features.notes = """\nkeep-me\n"""'), 'preserves the multiline dotted assignment'); + assert.strictEqual(countMatches(content, /^features\.hooks = true$/gm), 1, 'adds one dotted codex_hooks key'); + assert.ok(content.indexOf('features.hooks = true') > content.indexOf('"""'), 'inserts codex_hooks after the multiline assignment closes'); + assert.ok(content.indexOf('features.hooks = true') < content.indexOf('[model]'), 'inserts codex_hooks before the next table'); + assertNoDraftRootKeys(content); + }); + + test('existing empty [features] table is populated with one codex_hooks key', () => { + writeCodexConfig(codexHome, '[features]\r\n\r\n[model]\r\nname = "o3"\r\n'); + + runCodexInstall(codexHome); + + const content = readCodexConfig(codexHome); + assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'keeps one [features] section'); + assert.strictEqual(countMatches(content, /^hooks = true$/gm), 1, 'adds one codex_hooks key'); + assert.ok(content.includes('[features]\r\n\r\nhooks = true\r\n'), 'adds codex_hooks to empty table'); + assertUsesOnlyEol(content, '\r\n'); + assertNoDraftRootKeys(content); + }); + + test('multiline strings inside [features] do not create fake tables or fake codex_hooks matches', () => { + writeCodexConfig(codexHome, [ + '[features]', + 'notes = \'\'\'', + '[model]', + 'codex_hooks = false', + '\'\'\'', + 'other_feature = true', + '', + '[[hooks]]', + 'event = "AfterCommand"', + 'command = "echo custom-after-command"', + '', + ].join('\n')); + + runCodexInstall(codexHome); + + const content = readCodexConfig(codexHome); + assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'keeps one [features] section'); + assert.strictEqual(countMatches(content, /^hooks = true$/gm), 1, 'adds a real codex_hooks key once'); + assert.ok(content.includes('notes = \'\'\'\n[model]\ncodex_hooks = false\n\'\'\''), 'preserves multiline string content'); + assert.strictEqual(countMatches(content, /^codex_hooks = false$/gm), 1, 'does not rewrite codex_hooks text inside multiline string'); + assert.ok(content.indexOf('hooks = true') > content.indexOf('other_feature = true'), 'does not stop the features section at multiline string content'); + // Parse structurally — verify codex_hooks and migrated AfterCommand hook via parsed object + const parsed = parseTomlToObject(content); + assert.equal(parsed.features?.hooks, true, 'writes a real hooks boolean key (#3566)'); + assert.ok(Array.isArray(parsed.hooks?.AfterCommand), 'AfterCommand flat [[hooks]] migrated to namespaced AoT'); + const afterCmds = parsed.hooks.AfterCommand.flatMap((entry) => + Array.isArray(entry.hooks) ? entry.hooks.map((h) => h.command).filter(Boolean) : [] + ); + assert.ok(afterCmds.includes('echo custom-after-command'), 'preserves AfterCommand user hook command'); + assertNoDraftRootKeys(content); + }); + + test('non-boolean codex_hooks assignments are normalized to true without duplication', () => { + writeCodexConfig(codexHome, [ + '[features]', + 'codex_hooks = "sometimes"', + 'other_feature = true', + '', + '[model]', + 'name = "o3"', + '', + ].join('\n')); + + runCodexInstall(codexHome); + + const content = readCodexConfig(codexHome); + assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'keeps one [features] section'); + assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 1, 'normalizes to one true value'); + assert.ok(!content.includes('codex_hooks = "sometimes"'), 'removes non-boolean value'); + assert.ok(content.includes('other_feature = true'), 'preserves other feature keys'); + assertNoDraftRootKeys(content); + }); + + test('multiline basic-string codex_hooks assignments are fully normalized without leaving trailing lines behind', () => { + writeCodexConfig(codexHome, [ + '[features]', + 'codex_hooks = """', + 'multiline-basic-sentinel', + 'still-in-string', + '"""', + 'other_feature = true', + '', + '[model]', + 'name = "o3"', + '', + ].join('\n')); + + runCodexInstall(codexHome); + runCodexInstall(codexHome); + + const content = readCodexConfig(codexHome); + assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 1, 'replaces the multiline basic-string assignment with one true value'); + assert.ok(!content.includes('multiline-basic-sentinel'), 'removes multiline basic-string continuation lines'); + assert.ok(content.includes('other_feature = true'), 'preserves following feature keys'); + const hooksJsonCommands = readHooksSessionStartCommands(codexHome); + const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update')); + assert.strictEqual(gsdEntries.length, 1, 'remains idempotent for the GSD hook block in hooks.json'); + assertNoDraftRootKeys(content); + }); + + test('multiline literal-string codex_hooks assignments are fully normalized without leaving trailing lines behind', () => { + writeCodexConfig(codexHome, [ + '[features]', + 'codex_hooks = \'\'\'', + 'multiline-literal-sentinel', + 'still-in-literal', + '\'\'\'', + 'other_feature = true', + '', + '[model]', + 'name = "o3"', + '', + ].join('\n')); + + runCodexInstall(codexHome); + runCodexInstall(codexHome); + + const content = readCodexConfig(codexHome); + assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 1, 'replaces the multiline literal-string assignment with one true value'); + assert.ok(!content.includes('multiline-literal-sentinel'), 'removes multiline literal-string continuation lines'); + assert.ok(content.includes('other_feature = true'), 'preserves following feature keys'); + const hooksJsonCommands = readHooksSessionStartCommands(codexHome); + const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update')); + assert.strictEqual(gsdEntries.length, 1, 'remains idempotent for the GSD hook block in hooks.json'); + assertNoDraftRootKeys(content); + }); + + test('multiline array codex_hooks assignments are fully normalized without leaving trailing lines behind', () => { + writeCodexConfig(codexHome, [ + '[features]', + 'codex_hooks = [', + ' "array-sentinel-1",', + ' "array-sentinel-2",', + ']', + 'other_feature = true', + '', + '[model]', + 'name = "o3"', + '', + ].join('\n')); + + runCodexInstall(codexHome); + runCodexInstall(codexHome); + + const content = readCodexConfig(codexHome); + assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 1, 'replaces the multiline array assignment with one true value'); + assert.ok(!content.includes('array-sentinel-1'), 'removes multiline array continuation lines'); + assert.ok(!content.includes('array-sentinel-2'), 'removes multiline array continuation lines'); + assert.ok(content.includes('other_feature = true'), 'preserves following feature keys'); + const hooksJsonCommands = readHooksSessionStartCommands(codexHome); + const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update')); + assert.strictEqual(gsdEntries.length, 1, 'remains idempotent for the GSD hook block in hooks.json'); + assertNoDraftRootKeys(content); + }); + + test('triple-quoted codex_hooks values keep inline comments when normalized', () => { + writeCodexConfig(codexHome, [ + '[features]', + 'codex_hooks = """sometimes""" # keep me', + 'other_feature = true', + '', + '[model]', + 'name = "o3"', + '', + ].join('\n')); + + runCodexInstall(codexHome); + + const content = readCodexConfig(codexHome); + assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'keeps one [features] section'); + assert.strictEqual(countMatches(content, /^codex_hooks = true # keep me$/gm), 1, 'normalizes to true and preserves inline comment'); + assert.ok(!content.includes('"""sometimes"""'), 'removes the old triple-quoted value'); + assert.ok(content.includes('other_feature = true'), 'preserves other feature keys'); + assertNoDraftRootKeys(content); + }); + + test('existing CRLF codex_hooks = true stays single and preserves non-GSD hooks', () => { + writeCodexConfig(codexHome, [ + '[features]', + 'codex_hooks = true', + 'other_feature = true', + '', + '[[hooks]]', + 'event = "AfterCommand"', + 'command = "echo custom-after-command"', + '', + ].join('\r\n')); + + runCodexInstall(codexHome); + runCodexInstall(codexHome); + + const content = readCodexConfig(codexHome); + assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'keeps one [features] section'); + assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 1, 'keeps one codex_hooks = true'); + assert.ok(content.includes('other_feature = true'), 'preserves other feature keys'); + assert.strictEqual(countMatches(content, /echo custom-after-command/g), 1, 'preserves non-GSD hook exactly once'); + const hooksJsonCommands = readHooksSessionStartCommands(codexHome); + const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update')); + assert.strictEqual(gsdEntries.length, 1, 'keeps one GSD update hook in hooks.json'); + assertUsesOnlyEol(content, '\r\n'); + assertNoDraftRootKeys(content); + }); + + test('codex_hooks = true with an inline comment is treated as enabled for hook installation', () => { + writeCodexConfig(codexHome, [ + '[features]', + 'codex_hooks = true # keep me', + 'other_feature = true', + '', + '[model]', + 'name = "o3"', + '', + ].join('\n')); + + runCodexInstall(codexHome); + runCodexInstall(codexHome); + + const content = readCodexConfig(codexHome); + assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'keeps one [features] section'); + assert.strictEqual(countMatches(content, /^codex_hooks = true # keep me$/gm), 1, 'preserves the commented true value'); + assert.ok(content.includes('other_feature = true'), 'preserves other feature keys'); + const hooksJsonCommands = readHooksSessionStartCommands(codexHome); + const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update')); + assert.strictEqual(gsdEntries.length, 1, 'adds the GSD update hook once in hooks.json'); + assertNoDraftRootKeys(content); + }); + + test('mixed-EOL configs use the first newline style for inserted Codex content', () => { + writeCodexConfig(codexHome, '# first line wins\n[model]\r\nname = "o3"\r\n'); + + runCodexInstall(codexHome); + runCodexInstall(codexHome); + + const content = readCodexConfig(codexHome); + // [features] is inserted after top-level lines, before [model] — not prepended + assert.ok(content.includes('# first line wins\n\n[features]\nhooks = true\n'), 'inserts features after top-level lines using first newline style'); + assert.ok(content.includes(`# GSD Agent Configuration — managed by gsd-core installer\n`), 'writes the managed agent block using the first newline style'); + // Structural check: managed SessionStart hooks live in hooks.json. + const parsedMixed = parseTomlToObject(content); + assert.ok(!parsedMixed.hooks || !Array.isArray(parsedMixed.hooks.SessionStart), 'does not write managed SessionStart hooks to config.toml'); + const hooksJsonCommands = readHooksSessionStartCommands(codexHome); + const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update')); + assert.strictEqual(gsdEntries.length, 1, 'writes one managed SessionStart hook to hooks.json'); + assert.ok(content.includes('[model]\r\nname = "o3"'), 'preserves the existing CRLF model lines'); + assert.strictEqual(countMatches(content, /^hooks = true$/gm), 1, 'remains idempotent on repeated installs'); + assertNoDraftRootKeys(content); + }); +}); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/issue-2695-codex-hook-set.test.cjs — consolidation epic #1969 (H3 W4 #3336) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2695-codex-hook-set (consolidation epic #1969 H3 W4 #3336)", () => { +// Regression tests for #2695 — Codex native updates omit the update-hook worker +// and the managed-hooks registry. +// +// The Codex install branch in bin/install.js used to allowlist only two of the +// four hook files the shipped build emitted at the time (gsd-check-update.js + +// gsd-context-monitor.js — the latter permanently removed by #2586, see below), +// and gated the entire branch on !isMinimalMode so the +// `core` profile installed none of them. The parent SessionStart hook spawn()s +// the worker, which require()s the registry — so Codex was wired to a dependency +// chain the same installer never delivered. +// +// These tests drive the real installer (bin/install.js) behaviorally into an +// isolated temp config dir and assert the complete three-file set is delivered +// for both profiles, the registry is byte-for-byte, the version stamps resolve +// to the installed package version, and unrelated user files are preserved. +// +// #2586 reduced the set back to three: gsd-context-monitor.js read a Claude-only +// statusline bridge file Codex never writes, so it was a guaranteed silent no-op +// on every Codex hook event and was dropped from CODEX_HOOKS_TO_COPY for good. +// +// Verified non-duplicate: the pre-existing 'Codex install hook configuration +// (e2e)' suite above only asserts gsd-check-update.js delivery/wiring — it never +// asserts on gsd-check-update-worker.js, managed-hooks-registry.cjs, the +// core/full profile matrix, upgrade-refresh, byte-for-byte registry copy, +// idempotency of the three-file set, user-file preservation, or the +// core-profile negative-space (no agent files) — all genuinely distinct +// assertions this fold adds. + +'use strict'; + +const { test, describe, before } = 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 { runNode } = require('./helpers/process-seam.cjs'); +const { throwIfFailed } = require('./helpers/git-fixture.cjs'); + +const { cleanup } = require('./helpers.cjs'); +const { + INSTALL_SCRIPT, + BUILD_SCRIPT, + HOOKS_DIST, + installerEnv, +} = require('./helpers/install-shared.cjs'); + +const PKG_VERSION = require('../package.json').version; + +// #3145: class-norm timeouts, not per-suite values — see helpers/timeouts.cjs. +const { + BUILD_TIMEOUT_MS: BUILD_HOOKS_TIMEOUT_MS, + INSTALL_TIMEOUT_MS, +} = require('./helpers/timeouts.cjs'); + +// The three-file hook set the Codex surface must deliver together (#2695). +// gsd-context-monitor.js was removed from this set by #2586: it read a +// Claude-only statusline bridge file Codex never writes, so it was a +// guaranteed silent no-op on every Codex hook event. +const CODEX_HOOK_FILES = [ + 'gsd-check-update.js', + 'gsd-check-update-worker.js', + 'managed-hooks-registry.cjs', +]; + +// Build hooks/dist before any install runs (the installer copies from there). +before(() => { + const r = runNode([BUILD_SCRIPT], { timeoutMs: BUILD_HOOKS_TIMEOUT_MS }); + throwIfFailed(r, `node ${BUILD_SCRIPT}`); +}); + +function hooksDirOf(configDir) { + return path.join(configDir, 'hooks'); +} + +/** Run the Codex installer into an isolated temp config dir. */ +function runCodexInstall({ profile, preseed }) { + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), `gsd-2695-${profile}-`)); + if (preseed) { + const hooksDest = hooksDirOf(configDir); + fs.mkdirSync(hooksDest, { recursive: true }); + for (const [name, body] of Object.entries(preseed)) { + fs.writeFileSync(path.join(hooksDest, name), body); + } + } + // Sandbox HOME/USERPROFILE to configDir: Codex's skills-kind `home: ".agents"` + // override resolves via os.homedir(); sandboxing keeps the spawn self-contained + // (mirrors tests/install-minimal-hooks.test.cjs Codex downgrade test). + const result = runNode( + [INSTALL_SCRIPT, '--codex', '--global', '--config-dir', configDir, `--profile=${profile}`], + { env: installerEnv({ HOME: configDir, USERPROFILE: configDir }), timeoutMs: INSTALL_TIMEOUT_MS }, + ); + return { configDir, result }; +} + +// Older-version stamp used to pre-seed an "upgrade" scenario. +const OLDER_VERSION = '1.7.0'; + +describe('#2695: fresh Codex installs deliver the complete three-file hook set', () => { + for (const profile of ['core', 'full']) { + test(`fresh --profile=${profile} installs all three hook files`, (t) => { + const { configDir, result } = runCodexInstall({ profile }); + t.after(() => cleanup(configDir)); + + const hooksDir = hooksDirOf(configDir); + for (const file of CODEX_HOOK_FILES) { + assert.ok( + fs.existsSync(path.join(hooksDir, file)), + `expected ${file} under /hooks for --profile=${profile}\n` + + `installer stdout: ${result.stdout}\ninstaller stderr: ${result.stderr}`, + ); + } + }); + } +}); + +describe('#2695: Codex upgrades refresh all three hook files to the current version', () => { + // Pre-seed all three files stamped at OLDER_VERSION so an upgrade must overwrite them. + function olderSeed() { + const seed = {}; + for (const name of CODEX_HOOK_FILES) { + // Registry carries no version token; seed it with a stale sentinel body. + if (name.endsWith('.cjs')) { + seed[name] = `// stale registry ${OLDER_VERSION}\nmodule.exports = {};\n`; + } else { + seed[name] = `// gsd-hook-version: ${OLDER_VERSION}\n// stale\n`; + } + } + return seed; + } + + for (const profile of ['core', 'full']) { + test(`--profile=${profile} upgrade refreshes all three hook files`, (t) => { + const { configDir, result } = runCodexInstall({ profile, preseed: olderSeed() }); + t.after(() => cleanup(configDir)); + + const hooksDir = hooksDirOf(configDir); + // All three must now carry the current version stamp where one exists, and + // the registry must no longer be the stale sentinel. + for (const name of CODEX_HOOK_FILES) { + const dest = path.join(hooksDir, name); + assert.ok( + fs.existsSync(dest), + `expected refreshed ${name} for --profile=${profile}\n` + + `installer stdout: ${result.stdout}\ninstaller stderr: ${result.stderr}`, + ); + } + // The registry must be REFRESHED on upgrade, not merely present: assert it no + // longer carries the stale sentinel and now matches the shipped dist byte-for-byte + // (the raw-copy fallback must overwrite an existing dest, not skip it). + const registryDest = path.join(hooksDir, 'managed-hooks-registry.cjs'); + const registryBytes = fs.readFileSync(registryDest, 'utf8'); + assert.ok( + !registryBytes.includes(`stale registry ${OLDER_VERSION}`), + `registry must be refreshed on upgrade for --profile=${profile} (still carries the stale sentinel)`, + ); + assert.deepStrictEqual( + fs.readFileSync(registryDest), + fs.readFileSync(path.join(HOOKS_DIST, 'managed-hooks-registry.cjs')), + `refreshed registry must match hooks/dist byte-for-byte for --profile=${profile}`, + ); + // Version stamps resolved (acceptance #2/#3). + const workerStamp = readHookVersionLine(path.join(hooksDir, 'gsd-check-update-worker.js')); + assert.strictEqual( + workerStamp, PKG_VERSION, + `worker gsd-hook-version stamp must be the installed package version (${PKG_VERSION}), ` + + `got "${workerStamp}" for --profile=${profile}`, + ); + const parentStamp = readHookVersionLine(path.join(hooksDir, 'gsd-check-update.js')); + assert.strictEqual( + parentStamp, PKG_VERSION, + `parent gsd-check-update stamp must be the installed package version (${PKG_VERSION}), ` + + `got "${parentStamp}" for --profile=${profile}`, + ); + }); + } +}); + +describe('#2695: managed-hooks-registry.cjs is copied byte-for-byte', () => { + for (const profile of ['core', 'full']) { + test(`--profile=${profile} registry matches hooks/dist byte-for-byte`, (t) => { + const { configDir, result } = runCodexInstall({ profile }); + t.after(() => cleanup(configDir)); + + const dest = path.join(hooksDirOf(configDir), 'managed-hooks-registry.cjs'); + assert.ok(fs.existsSync(dest), `registry missing for --profile=${profile}\nstdout: ${result.stdout}`); + const distBytes = fs.readFileSync(path.join(HOOKS_DIST, 'managed-hooks-registry.cjs')); + const destBytes = fs.readFileSync(dest); + assert.deepStrictEqual( + destBytes, distBytes, + `managed-hooks-registry.cjs must be copied byte-for-byte (no version/path transform) for --profile=${profile}`, + ); + }); + } +}); + +describe('#2695: worker hook-version stamp is a literal install-time value', () => { + test('the stamp is the literal package version, never a placeholder or a runtime lookup', (t) => { + const { configDir } = runCodexInstall({ profile: 'full' }); + t.after(() => cleanup(configDir)); + + const workerPath = path.join(hooksDirOf(configDir), 'gsd-check-update-worker.js'); + const content = fs.readFileSync(workerPath, 'utf8'); + // The placeholder must have been replaced — a leftover {{GSD_VERSION}} is the bug shape. + assert.ok( + !content.includes('{{GSD_VERSION}}'), + 'worker still carries an unresolved {{GSD_VERSION}} placeholder — stamping did not run', + ); + // And the resolved value must be the literal version, present on the version-comment line. + const stamp = readHookVersionLine(workerPath); + assert.strictEqual(stamp, PKG_VERSION, `worker stamp must equal package.json version, got "${stamp}"`); + }); +}); + +describe('#2695: unrelated user-owned hook files are preserved', () => { + for (const profile of ['core', 'full']) { + test(`--profile=${profile} leaves a pre-existing user hook untouched`, (t) => { + const userOwned = 'my-custom-hook.js'; + const userBody = '// user-owned hook — do not touch\nconsole.log("mine");\n'; + const { configDir, result } = runCodexInstall({ profile, preseed: { [userOwned]: userBody } }); + t.after(() => cleanup(configDir)); + + const dest = path.join(hooksDirOf(configDir), userOwned); + assert.ok(fs.existsSync(dest), `user-owned ${userOwned} must be preserved for --profile=${profile}\nstdout: ${result.stdout}`); + assert.strictEqual( + fs.readFileSync(dest, 'utf8'), userBody, + `user-owned ${userOwned} bytes must be unchanged for --profile=${profile}`, + ); + }); + } +}); + +describe('#2695: re-running the installer is idempotent for the three-file set', () => { + test('a second full install leaves all three files present and correctly stamped', (t) => { + const first = runCodexInstall({ profile: 'full' }); + t.after(() => cleanup(first.configDir)); + // Second run into the SAME config dir. + const result2 = runNode( + [INSTALL_SCRIPT, '--codex', '--global', '--config-dir', first.configDir, '--profile=full'], + { env: installerEnv({ HOME: first.configDir, USERPROFILE: first.configDir }), timeoutMs: INSTALL_TIMEOUT_MS }, + ); + assert.ok(result2.stdout || result2.stderr); + + const hooksDir = hooksDirOf(first.configDir); + for (const name of CODEX_HOOK_FILES) { + assert.ok(fs.existsSync(path.join(hooksDir, name)), `${name} must survive a second install`); + } + assert.strictEqual( + readHookVersionLine(path.join(hooksDir, 'gsd-check-update-worker.js')), + PKG_VERSION, + 'worker stamp must remain correct after a second install', + ); + }); +}); + +describe('#2695: the core profile enables the hook feature and wires SessionStart (intended)', () => { + // For the update-check/context-monitor hooks to actually fire, Codex needs both + // the feature flag in config.toml AND the hooks.json routing — copying inert + // files alone would leave `core` with scripts Codex never invokes. Entering the + // codex-toml branch for `core` (the #2695 gate change) synthesizes `[features] + // hooks = true` via ensureCodexHooksFeature, writes config.toml, and registers + // the hooks. This is the intended behavior of the fix, not a side effect — these + // assertions pin it so a future re-gating cannot silently regress it. + test('--profile=core writes config.toml enabling the hooks feature', (t) => { + const { configDir } = runCodexInstall({ profile: 'core' }); + t.after(() => cleanup(configDir)); + + const configPath = path.join(configDir, 'config.toml'); + assert.ok(fs.existsSync(configPath), 'core must write config.toml so the hooks feature is enabled'); + const config = fs.readFileSync(configPath, 'utf8'); + assert.ok(/^\s*hooks\s*=\s*true\s*$/m.test(config), 'config.toml must enable hooks = true for core'); + }); + + test('--profile=core wires the SessionStart update-check hook in hooks.json', (t) => { + const { configDir } = runCodexInstall({ profile: 'core' }); + t.after(() => cleanup(configDir)); + + const hooksJsonPath = path.join(configDir, 'hooks.json'); + assert.ok(fs.existsSync(hooksJsonPath), 'core must write hooks.json'); + const hooksJson = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8')); + const sessionStartCmds = collectHookCommands(hooksJson, 'SessionStart'); + // The command points at the gsd-check-update hook script. Its extension is + // platform-specific — Windows routes through a .cmd shim, POSIX through .js — + // so assert on the basename prefix, not a hardcoded extension (Windows parity). + const routedToUpdateHook = sessionStartCmds.some((c) => { + const token = c.replace(/"/g, '').replace(/\\/g, '/'); + const segs = token.split('/'); + const last = segs[segs.length - 1]; + return last.startsWith('gsd-check-update.'); + }); + assert.ok( + routedToUpdateHook, + `core must route SessionStart to the gsd-check-update hook in hooks.json; got: ${JSON.stringify(sessionStartCmds)}`, + ); + }); +}); + +describe('#2695: the core profile still installs no agent files (negative space)', () => { + test('--profile=core delivers hooks but no gsd-* agent files', (t) => { + const { configDir } = runCodexInstall({ profile: 'core' }); + t.after(() => cleanup(configDir)); + + // Hooks delivered (the fix)… + for (const name of CODEX_HOOK_FILES) { + assert.ok(fs.existsSync(path.join(hooksDirOf(configDir), name)), `${name} delivered for core`); + } + // …but the full agent surface is still absent (core stays minimal). Codex agents + // are .toml ([agents.gsd-*] in config.toml + agents/gsd-*.toml), so check both + // extensions — a .md-only filter would miss a Codex agent-surface regression. + const agentsDir = path.join(configDir, 'agents'); + if (fs.existsSync(agentsDir)) { + const gsdAgents = fs.readdirSync(agentsDir).filter( + (f) => f.startsWith('gsd-') && (f.endsWith('.md') || f.endsWith('.toml')), + ); + assert.deepStrictEqual(gsdAgents, [], 'core must not install the full agent surface'); + } + // And config.toml must carry no agent role sections. + const configPath = path.join(configDir, 'config.toml'); + if (fs.existsSync(configPath)) { + const config = fs.readFileSync(configPath, 'utf8'); + assert.ok( + !/^\[agents\.gsd-/m.test(config), + 'core config.toml must not declare [agents.gsd-*] roles (full agent surface stays a full-profile concern)', + ); + } + }); +}); + +/** + * Read the `// gsd-hook-version: ` comment value from a hook file. + * Returns the trimmed literal. Used so tests assert on the structured stamp, + * not on raw `.includes()` prose (CONTRIBUTING raw-text-matching rule). + */ +function readHookVersionLine(hookPath) { + const content = fs.readFileSync(hookPath, 'utf8'); + const m = content.match(/^\/\/ gsd-hook-version:\s*(.+?)\s*$/m); + return m ? m[1] : null; +} + +/** + * Collect every hook command string registered under a given Codex hooks.json + * event key. Used so the SessionStart-wiring test asserts on the structured + * hook entries (commands), not on raw text matching against the whole file. + */ +function collectHookCommands(hooksJson, eventName) { + const entries = (hooksJson && hooksJson.hooks && Array.isArray(hooksJson.hooks[eventName])) + ? hooksJson.hooks[eventName] + : []; + return entries.flatMap((entry) => + (entry && Array.isArray(entry.hooks) ? entry.hooks : []) + .map((h) => (h && typeof h.command === 'string' ? h.command : null)) + .filter(Boolean), + ); +} + }); +} + + +describe('Codex uninstall symmetry for hook-enabled configs', () => { + let tmpDir; + let codexHome; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-codex-uninstall-')); + codexHome = path.join(tmpDir, 'codex-home'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('fresh install removes the GSD-added codex_hooks feature on uninstall', () => { + runCodexInstall(codexHome); + + const cleaned = stripGsdFromCodexConfig(readCodexConfig(codexHome)); + assert.strictEqual(cleaned, null, 'fresh GSD-only config strips back to nothing'); + }); + + test('install then uninstall removes [features].codex_hooks while preserving other feature keys, comments, hooks, and CRLF', () => { + writeCodexConfig(codexHome, [ + '[features]', + '# keep me', + 'other_feature = true', + '', + '[[hooks]]', + 'event = "AfterCommand"', + 'command = "echo custom-after-command"', + '', + '[model]', + 'name = "o3"', + '', + ].join('\r\n')); + + runCodexInstall(codexHome); + + const cleaned = stripGsdFromCodexConfig(readCodexConfig(codexHome)); + assert.ok(cleaned, 'preserves user config after uninstall cleanup'); + assert.strictEqual(countMatches(cleaned, /^\[features\](?:\s*#.*)?$/gm), 1, 'keeps the existing features table'); + assert.strictEqual(countMatches(cleaned, /^codex_hooks = true$/gm), 0, 'removes the GSD-added codex_hooks key'); + assert.ok(cleaned.includes('# keep me'), 'preserves user comments in [features]'); + assert.ok(cleaned.includes('other_feature = true'), 'preserves other feature keys'); + assert.strictEqual(countMatches(cleaned, /echo custom-after-command/g), 1, 'preserves non-GSD hooks'); + assert.strictEqual(countMatches(cleaned, /gsd-check-update\.js/g), 0, 'removes only the GSD update hook'); + assert.strictEqual(countMatches(cleaned, /\[agents\.gsd-/g), 0, 'removes managed GSD agent sections'); + assertUsesOnlyEol(cleaned, '\r\n'); + }); + + test('install then uninstall removes dotted features.codex_hooks without creating a [features] table', () => { + writeCodexConfig(codexHome, [ + 'features.other_feature = true', + '', + '[[hooks]]', + 'event = "AfterCommand"', + 'command = "echo custom-after-command"', + '', + '[model]', + 'name = "o3"', + '', + ].join('\n')); + + runCodexInstall(codexHome); + + const cleaned = stripGsdFromCodexConfig(readCodexConfig(codexHome)); + assert.ok(cleaned.includes('features.other_feature = true'), 'preserves other dotted feature keys'); + assert.strictEqual(countMatches(cleaned, /^features\.codex_hooks = true$/gm), 0, 'removes the dotted GSD codex_hooks key'); + assert.strictEqual(countMatches(cleaned, /^\[features\]\s*$/gm), 0, 'does not leave behind a [features] table'); + assert.strictEqual(countMatches(cleaned, /echo custom-after-command/g), 1, 'preserves non-GSD hooks'); + assert.strictEqual(countMatches(cleaned, /gsd-check-update\.js/g), 0, 'removes the GSD update hook'); + }); + + test('install then uninstall preserves a pre-existing [features].codex_hooks = true', () => { + writeCodexConfig(codexHome, [ + '[features]', + 'codex_hooks = true', + 'other_feature = true', + '', + '[model]', + 'name = "o3"', + '', + ].join('\n')); + + runCodexInstall(codexHome); + + const cleaned = stripGsdFromCodexConfig(readCodexConfig(codexHome)); + assert.ok(cleaned.includes('[features]\ncodex_hooks = true\nother_feature = true'), 'preserves the user-authored codex_hooks assignment'); + assert.strictEqual(countMatches(cleaned, /^codex_hooks = true$/gm), 1, 'keeps the pre-existing codex_hooks key'); + assert.strictEqual(countMatches(cleaned, /gsd-check-update\.js/g), 0, 'removes the GSD update hook'); + assert.strictEqual(countMatches(cleaned, /\[agents\.gsd-/g), 0, 'removes managed GSD agent sections'); + }); + + test('install then uninstall preserves a pre-existing quoted [features]."codex_hooks" = true', () => { + writeCodexConfig(codexHome, [ + '[features]', + '"codex_hooks" = true', + 'other_feature = true', + '', + '[model]', + 'name = "o3"', + '', + ].join('\n')); + + runCodexInstall(codexHome); + + const cleaned = stripGsdFromCodexConfig(readCodexConfig(codexHome)); + assert.ok(cleaned.includes('[features]\n"codex_hooks" = true\nother_feature = true'), 'preserves the user-authored quoted codex_hooks assignment'); + assert.strictEqual(countMatches(cleaned, /^"codex_hooks" = true$/gm), 1, 'keeps the pre-existing quoted codex_hooks key'); + assert.strictEqual(countMatches(cleaned, /gsd-check-update\.js/g), 0, 'removes the GSD update hook'); + assert.strictEqual(countMatches(cleaned, /\[agents\.gsd-/g), 0, 'removes managed GSD agent sections'); + }); + + test('install then uninstall preserves a pre-existing root dotted features.codex_hooks = true', () => { + writeCodexConfig(codexHome, [ + 'features.codex_hooks = true', + 'features.other_feature = true', + '', + '[model]', + 'name = "o3"', + '', + ].join('\n')); + + runCodexInstall(codexHome); + + const cleaned = stripGsdFromCodexConfig(readCodexConfig(codexHome)); + assert.ok(cleaned.includes('features.codex_hooks = true\nfeatures.other_feature = true'), 'preserves the user-authored dotted codex_hooks assignment'); + assert.strictEqual(countMatches(cleaned, /^features\.codex_hooks = true$/gm), 1, 'keeps the pre-existing dotted codex_hooks key'); + assert.strictEqual(countMatches(cleaned, /gsd-check-update\.js/g), 0, 'removes the GSD update hook'); + assert.strictEqual(countMatches(cleaned, /\[agents\.gsd-/g), 0, 'removes managed GSD agent sections'); + }); + + test('install then uninstall leaves short-circuited root features assignments untouched', () => { + const cases = [ + 'features = { other_feature = true }\n\n[model]\nname = "o3"\n', + 'features = "disabled"\n\n[model]\nname = "o3"\n', + ]; + + for (const initialContent of cases) { + writeCodexConfig(codexHome, initialContent); + runCodexInstall(codexHome); + + const cleaned = stripGsdFromCodexConfig(readCodexConfig(codexHome)); + assert.strictEqual(cleaned, initialContent, `preserves short-circuited root features assignment: ${initialContent.split(/\r?\n/)[0]}`); + + cleanup(codexHome); + fs.mkdirSync(codexHome, { recursive: true }); + } + }); + + test('install then uninstall keeps mixed-EOL user content stable while removing GSD hook state', () => { + const initialContent = [ + '# first line wins', + '[features]', + 'other_feature = true', + '', + '[model]', + 'name = "o3"', + '', + ].join('\r\n').replace(/^# first line wins\r\r?\n/, '# first line wins\n'); + + writeCodexConfig(codexHome, initialContent); + runCodexInstall(codexHome); + + const cleaned = stripGsdFromCodexConfig(readCodexConfig(codexHome)); + assert.ok(cleaned.includes('# first line wins\n[features]\r\nother_feature = true\r\n\r\n[model]\r\nname = "o3"'), 'preserves the original mixed-EOL user content'); + assert.strictEqual(countMatches(cleaned, /^codex_hooks = true$/gm), 0, 'removes the injected codex_hooks key'); + assert.strictEqual(countMatches(cleaned, /gsd-check-update\.js/g), 0, 'removes the GSD update hook'); + assert.strictEqual(countMatches(cleaned, /\[agents\.gsd-/g), 0, 'removes managed GSD agent sections'); + }); +}); + +// ─── #1326: cleanupCodexSkillMetadataSidecars (replaces #774 writeCodexSkillMetadataFiles) ── + +describe('cleanupCodexSkillMetadataSidecars (#1326)', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-sidecar-cleanup-')); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('Codex install does not emit managed agents/openai.yaml sidecars and removes stale ones (#1326)', () => { + // gsd-foo: managed skill with stale sidecar → sidecar removed, empty agents/ pruned + const fooAgents = path.join(tmpDir, 'gsd-foo', 'agents'); + fs.mkdirSync(fooAgents, { recursive: true }); + fs.writeFileSync(path.join(tmpDir, 'gsd-foo', 'SKILL.md'), '---\nname: gsd-foo\n---\nBody.\n'); + fs.writeFileSync(path.join(fooAgents, 'openai.yaml'), 'interface:\n display_name: "foo"\n'); + + // gsd-dev-preferences: user-owned → sidecar PRESERVED + const prefAgents = path.join(tmpDir, 'gsd-dev-preferences', 'agents'); + fs.mkdirSync(prefAgents, { recursive: true }); + fs.writeFileSync(path.join(tmpDir, 'gsd-dev-preferences', 'SKILL.md'), '---\nname: gsd-dev-preferences\n---\nBody.\n'); + const userYaml = 'interface:\n display_name: "my prefs"\n short_description: "User-authored"\n'; + fs.writeFileSync(path.join(prefAgents, 'openai.yaml'), userYaml); + + // gsd-bar: managed skill with sidecar + another file in agents/ → sidecar removed, agents/ kept (has other.txt) + const barAgents = path.join(tmpDir, 'gsd-bar', 'agents'); + fs.mkdirSync(barAgents, { recursive: true }); + fs.writeFileSync(path.join(tmpDir, 'gsd-bar', 'SKILL.md'), '---\nname: gsd-bar\n---\nBody.\n'); + fs.writeFileSync(path.join(barAgents, 'openai.yaml'), 'interface:\n display_name: "bar"\n'); + fs.writeFileSync(path.join(barAgents, 'other.txt'), 'some other content\n'); + + // helper: non-gsd dir with openai.yaml → UNTOUCHED + const helperAgents = path.join(tmpDir, 'helper', 'agents'); + fs.mkdirSync(helperAgents, { recursive: true }); + fs.writeFileSync(path.join(helperAgents, 'openai.yaml'), 'interface:\n display_name: "helper"\n'); + + cleanupCodexSkillMetadataSidecars(tmpDir); + + // gsd-foo: sidecar removed and empty agents/ pruned + assert.ok(!fs.existsSync(path.join(fooAgents, 'openai.yaml')), + 'gsd-foo/agents/openai.yaml must be removed (managed stale sidecar)'); + assert.ok(!fs.existsSync(fooAgents), + 'gsd-foo/agents/ must be pruned when empty after sidecar removal'); + + // gsd-dev-preferences: user-owned, sidecar preserved + assert.ok(fs.existsSync(path.join(prefAgents, 'openai.yaml')), + 'gsd-dev-preferences/agents/openai.yaml must be preserved (user-owned)'); + assert.strictEqual(fs.readFileSync(path.join(prefAgents, 'openai.yaml'), 'utf8'), userYaml, + 'gsd-dev-preferences/agents/openai.yaml content must be unchanged'); + + // gsd-bar: sidecar removed but agents/ kept (still has other.txt) + assert.ok(!fs.existsSync(path.join(barAgents, 'openai.yaml')), + 'gsd-bar/agents/openai.yaml must be removed'); + assert.ok(fs.existsSync(barAgents), + 'gsd-bar/agents/ must NOT be pruned (still contains other.txt)'); + assert.ok(fs.existsSync(path.join(barAgents, 'other.txt')), + 'gsd-bar/agents/other.txt must be preserved'); + + // helper: non-gsd dir untouched + assert.ok(fs.existsSync(path.join(helperAgents, 'openai.yaml')), + 'helper/agents/openai.yaml must be untouched (non-gsd dir)'); + }); + + test('is a no-op when skillsDir does not exist (#1326)', () => { + assert.doesNotThrow(() => { + cleanupCodexSkillMetadataSidecars(path.join(tmpDir, 'nonexistent')); + }, 'must not throw when skillsDir does not exist'); + }); + + test('is a no-op for managed gsd-* dirs with no agents/openai.yaml (#1326)', () => { + // No sidecar present — should not throw, should not create anything + const skillDir = path.join(tmpDir, 'gsd-baz'); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '---\nname: gsd-baz\n---\nBody.\n'); + + assert.doesNotThrow(() => { + cleanupCodexSkillMetadataSidecars(tmpDir); + }, 'must not throw when no sidecar exists'); + assert.ok(!fs.existsSync(path.join(skillDir, 'agents')), + 'must not create agents/ dir when no sidecar was present'); + }); + + test('does not delete through a symlinked agents/ directory (#1326)', { skip: process.platform === 'win32' }, () => { + // Setup: a skills dir with gsd-foo/ whose agents/ is a SYMLINK to an external dir. + // The cleanup must not delete files through the symlink. + const externalDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-symlink-ext-')); + try { + // Place openai.yaml and a sentinel in the external dir. + fs.writeFileSync(path.join(externalDir, 'openai.yaml'), 'interface:\n display_name: "external"\n'); + fs.writeFileSync(path.join(externalDir, 'keep.txt'), 'sentinel\n'); + + // Create gsd-foo/ in the skills dir and make agents/ a symlink to externalDir. + const skillDir = path.join(tmpDir, 'gsd-foo'); + fs.mkdirSync(skillDir, { recursive: true }); + const agentsLink = path.join(skillDir, 'agents'); + fs.symlinkSync(externalDir, agentsLink, 'dir'); + + cleanupCodexSkillMetadataSidecars(tmpDir); + + // Nothing in the external dir must have been deleted. + assert.ok(fs.existsSync(path.join(externalDir, 'openai.yaml')), + 'external/openai.yaml must still exist — cleanup must not delete through a symlinked agents/ dir'); + assert.ok(fs.existsSync(path.join(externalDir, 'keep.txt')), + 'external/keep.txt must still exist — cleanup must not delete through a symlinked agents/ dir'); + // The symlink itself must still be present. + assert.ok(fs.existsSync(agentsLink), + 'gsd-foo/agents symlink must still exist'); + } finally { + cleanup(externalDir); + } + }); + + test('Codex install does not create agents/openai.yaml sidecars for any managed skill (#1326)', () => { + // Integration test: full Codex install must NOT produce any managed gsd-*/agents/openai.yaml + const codexHome = path.join(tmpDir, 'codex-home'); + fs.mkdirSync(codexHome, { recursive: true }); + runCodexInstall(codexHome); + 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'); + assert.ok(gsdSkillDirs.length > 0, 'install must create at least one managed gsd-* skill directory'); + for (const skillEntry of gsdSkillDirs) { + const yamlPath = path.join(skillsDir, skillEntry.name, 'agents', 'openai.yaml'); + assert.ok(!fs.existsSync(yamlPath), + `${skillEntry.name}/agents/openai.yaml must NOT exist after install (#1326 sidecar dedup)`); + } + }); +}); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2698-crlf-install.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2698-crlf-install (consolidation epic #1969 B1 #1970)", () => { +// allow-test-rule: source-text-is-the-product (see #2698) +// Workflow .md / agent .md / command .md / reference .md files — their text +// IS what the runtime loads. Testing text content tests the deployed contract. +// Per CONTRIBUTING.md exception matrix. + +/** + * Regression test for #2698: CRLF line endings break agent-block strip regexes + * + * The legacy `gsd-update-check` hook migration in bin/install.js uses two + * separate .replace() calls: + * 1. LF-only regex: /\n# GSD Hooks\n\[\[hooks\]\]\nevent = ...\n/ + * 2. CRLF-only regex: /\r\n# GSD Hooks\r\n\[\[hooks\]\]\r\nevent = ...\r\n/ + * + * These patterns fail when config.toml has mixed line endings — e.g. the + * "# GSD Hooks" header uses LF but the body uses CRLF, or vice versa. This + * can happen when the file is created cross-platform (Windows/Linux), when + * editors convert only part of the file, or when a previous GSD version wrote + * the block with different EOL than the file's dominant EOL. + * + * Fix: consolidate to a single \r?\n-aware regex that handles LF, CRLF, and + * any mix in a single pass, making the migration robust regardless of the + * platform the file was last written on. + * + * Test approach: write a `.codex/config.toml` with a stale gsd-update-check + * block that uses mixed line endings (header in LF, body in CRLF), then run + * install() and assert the stale block is gone. + * + * Note: The local Codex install writes to `.codex/` in the current directory. + * Tests `process.chdir(tmpDir)` and write fixtures to `tmpDir/.codex/`. + */ + +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +const { describe, test, before, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { runNode } = require('./helpers/process-seam.cjs'); +const { throwIfFailed } = require('./helpers/git-fixture.cjs'); + +const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js'); +const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); +// scripts/build-hooks.js copies pre-built hook files into hooks/dist and +// syntax-checks them with vm — it does not compile/bundle anything. See +// tests/helpers/timeouts.cjs for the class-norm justification. +const { BUILD_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); +const { install, GSD_CODEX_MARKER } = require(INSTALL_SRC); +const { cleanup } = require('./helpers.cjs'); + +// Ensure hooks/dist/ is populated before install tests +before(() => { + throwIfFailed( + runNode([BUILD_SCRIPT], { timeoutMs: BUILD_TIMEOUT_MS }), + `node ${BUILD_SCRIPT}`, + ); +}); + +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); + }); + + // Helper: pre-populate .codex/config.toml with a GSD marker + stale hooks block + // using the given line ending for the stale hooks block header, and a potentially + // different EOL for the hooks body. This exercises the cross-platform mixed scenario. +function writeCodexConfigWithStaleHooks(dir, headerEol, bodyEol) { + // Build the stale block with header EOL for the "# GSD Hooks" line, but body EOL + // for the content lines (simulates a file edited by two different platforms). + const staleBlock = [ + '# GSD Hooks', // line that starts the stale section + '[[hooks]]', + 'event = "SessionStart"', + 'command = "node /old/path/gsd-update-check.js"', + ].join(bodyEol); + + // Put the stale block in user content BEFORE the GSD marker. The GSD marker area + // will be regenerated by mergeCodexConfig during install(); the stale block in + // the user area is what the hooks migration must remove. + const content = [ + '[features]', + 'codex_hooks = true', + '', + ].join(headerEol) + headerEol + staleBlock + headerEol + headerEol + GSD_CODEX_MARKER + headerEol; + + const codexDir = path.join(dir, '.codex'); + fs.mkdirSync(codexDir, { recursive: true }); + const configPath = path.join(codexDir, 'config.toml'); + fs.writeFileSync(configPath, content, 'utf-8'); + return configPath; + } + + function readHooksSessionStartCommands(codexHome) { + const hooksPath = path.join(codexHome, 'hooks.json'); + if (!fs.existsSync(hooksPath)) return []; + const raw = fs.readFileSync(hooksPath, 'utf8').trim(); + if (!raw) return []; + const parsed = JSON.parse(raw); + const table = (parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks)) + ? parsed.hooks + : parsed; + const sessionStart = Array.isArray(table.SessionStart) ? table.SessionStart : []; + return sessionStart.flatMap((entry) => [ + ...(typeof entry?.command === 'string' ? [entry.command] : []), + ...(Array.isArray(entry?.hooks) + ? entry.hooks.map((hook) => hook && hook.command).filter((cmd) => typeof cmd === 'string') + : []), + ]); + } + + test('LF config.toml: stale gsd-update-check block removed on reinstall', (t) => { + const origCwd = process.cwd(); + t.after(() => { process.chdir(origCwd); }); + process.chdir(tmpDir); + + writeCodexConfigWithStaleHooks(tmpDir, '\n', '\n'); + install(false, 'codex'); + + const configPath = path.join(tmpDir, '.codex', 'config.toml'); + const content = fs.readFileSync(configPath, 'utf-8'); + + assert.ok( + !content.includes('gsd-update-check'), + 'Stale gsd-update-check entry must be removed from LF config.toml (#2698)' + ); + const hooksJsonCommands = readHooksSessionStartCommands(path.join(tmpDir, '.codex')); + assert.equal( + hooksJsonCommands.some((cmd) => cmd.includes('gsd-check-update')), + true, + 'New gsd-check-update hook must appear in hooks.json after reinstall' + ); + }); + + test('CRLF config.toml: stale gsd-update-check block removed on reinstall', (t) => { + const origCwd = process.cwd(); + t.after(() => { process.chdir(origCwd); }); + process.chdir(tmpDir); + + writeCodexConfigWithStaleHooks(tmpDir, '\r\n', '\r\n'); + install(false, 'codex'); + + const configPath = path.join(tmpDir, '.codex', 'config.toml'); + const content = fs.readFileSync(configPath, 'utf-8'); + + assert.ok( + !content.includes('gsd-update-check'), + 'Stale gsd-update-check entry must be removed from CRLF config.toml (#2698)' + ); + const hooksJsonCommands = readHooksSessionStartCommands(path.join(tmpDir, '.codex')); + assert.equal( + hooksJsonCommands.some((cmd) => cmd.includes('gsd-check-update')), + true, + 'New gsd-check-update hook must appear in hooks.json after reinstall' + ); + }); + + test('mixed-EOL config.toml: stale block with LF header but CRLF body removed on reinstall', (t) => { + // This is the primary failure case: header line uses LF but the body uses CRLF. + // The old LF-only regex requires all-\n separators; the old CRLF-only regex requires + // all-\r\n separators. Neither matches a block with mixed endings, so the stale + // block survives reinstall with the old code (#2698). + const origCwd = process.cwd(); + t.after(() => { process.chdir(origCwd); }); + process.chdir(tmpDir); + + // headerEol='\n' (file dominant), bodyEol='\r\n' (hook block from another platform) + writeCodexConfigWithStaleHooks(tmpDir, '\n', '\r\n'); + install(false, 'codex'); + + const configPath = path.join(tmpDir, '.codex', 'config.toml'); + const content = fs.readFileSync(configPath, 'utf-8'); + + assert.ok( + !content.includes('gsd-update-check'), + [ + 'Stale gsd-update-check block with mixed LF/CRLF endings must be removed (#2698).', + 'Old code used two separate LF-only and CRLF-only regexes; neither matched mixed content.', + 'Fix consolidates to a single \\r?\\n-aware regex.', + ].join(' ') + ); + }); +}); + }); +} diff --git a/tests/codex-config-hooks.test.cjs b/tests/codex-config-hooks.test.cjs new file mode 100644 index 000000000..1b35c543e --- /dev/null +++ b/tests/codex-config-hooks.test.cjs @@ -0,0 +1,3629 @@ +/** + * GSD Tools Tests - codex-config.cjs + * + * Tests for Codex adapter header, agent conversion, config.toml generation/merge, + * per-agent .toml generation, and uninstall cleanup. + */ + +// Enable test exports from install.js (skips main CLI logic) +process.env.GSD_TEST_MODE = '1'; + +const { test: _test, describe: _describe, before, beforeEach: _beforeEach, afterEach: _afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const _os = require('os'); +const { runNode } = require('./helpers/process-seam.cjs'); +const { throwIfFailed } = require('./helpers/git-fixture.cjs'); +const { cleanup: _cleanup } = require('./helpers.cjs'); +const _fc = require('fast-check'); +const { CLAUDE_AGENT_ALIASES: _CLAUDE_AGENT_ALIASES } = require('../gsd-core/bin/lib/model-resolver.cjs'); +const { escapeRegex } = require('../gsd-core/bin/lib/pattern.cjs'); +// #3241 — the intended new home for CLAUDE_AGENT_ALIASES + isAnthropicFlavoredModel +// (see .gsd/phase/feat-3241-codex-omit-model-by-default/40-design.md "The seam +// decision"). Neither export exists on model-catalog.cjs yet; requiring the +// module does not throw (it just has no such keys today), but calling +// isAnthropicFlavoredModel does — see the new describe block below. +const _modelCatalog = require('../gsd-core/bin/lib/model-catalog.cjs'); +const _modelResolver = require('../gsd-core/bin/lib/model-resolver.cjs'); + +// #2153 follow-up: ensure hooks/dist/ exists before any install integration +// test runs. The Codex install path copies hook files from hooks/dist/, which +// is gitignored and only populated by `npm run build:hooks`. When one of the +// codex-config*.test.cjs files is run in isolation (`node --test +// tests/codex-config-agents.test.cjs`, for example) the build step from the +// npm-test pretest chain does not run, and the "Codex install copies hook +// file" regression silently fails because hooks/dist/ is empty. +// Build on demand so the test passes regardless of runner ordering. +const HOOKS_DIST = path.join(__dirname, '..', 'hooks', 'dist'); +const BUILD_HOOKS_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); +// scripts/build-hooks.js copies pre-built hook files into hooks/dist and +// syntax-checks them with vm — it does not compile/bundle anything. See +// tests/helpers/timeouts.cjs for the class-norm justification. +const { BUILD_TIMEOUT_MS: BUILD_HOOKS_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); +before(() => { + if (!fs.existsSync(HOOKS_DIST) || fs.readdirSync(HOOKS_DIST).length === 0) { + throwIfFailed( + runNode([BUILD_HOOKS_SCRIPT], { timeoutMs: BUILD_HOOKS_TIMEOUT_MS }), + `node ${BUILD_HOOKS_SCRIPT}`, + ); + } +}); + +const { + getCodexSkillAdapterHeader: _getCodexSkillAdapterHeader, + convertClaudeAgentToCodexAgent: _convertClaudeAgentToCodexAgent, + convertClaudeCommandToCodexSkill: _convertClaudeCommandToCodexSkill, + generateCodexAgentToml: _generateCodexAgentToml, + _resetCodexWarningDedupeForTests: __resetCodexWarningDedupeForTests, + cleanupCodexSkillMetadataSidecars: _cleanupCodexSkillMetadataSidecars, + generateCodexConfigBlock: _generateCodexConfigBlock, + stripGsdFromCodexConfig: _stripGsdFromCodexConfig, + migrateCodexHooksMapFormat: _migrateCodexHooksMapFormat, + mergeCodexConfig: _mergeCodexConfig, + install, + GSD_CODEX_MARKER: _GSD_CODEX_MARKER, + deriveCodexSandboxMode: _deriveCodexSandboxMode, + // #3897 rung 3 (ADR-3473 §8.3, option 2 — HALT.md): anticipated new export + // holding the 17 explicit read-only pins for roles whose tool contract would + // otherwise derive workspace-write (16 measured by HALT.md + gsd-nyquist-auditor, + // surfaced by the list-form parse fix). Does not exist on the current tree — + // destructuring a non-existent key is `undefined`, not a throw, so requiring + // this module still succeeds; every test below that touches it fails on its + // own `typeof` guard instead. + CODEX_SANDBOX_HOLDS: _CODEX_SANDBOX_HOLDS, + parseTomlToObject: _parseTomlToObject, + validateCodexConfigSchema: _validateCodexConfigSchema, + uninstall: _uninstall, + CODEX_EXTENDED_HOOK_EVENTS: _CODEX_EXTENDED_HOOK_EVENTS, +} = require('../bin/install.js'); + +const { resolveNodeRunner: _resolveNodeRunner } = require('../gsd-core/bin/lib/runtime-hooks-surface.cjs'); +const { resolveInstallPlan: _resolveInstallPlan } = require('../gsd-core/bin/lib/runtime-config-adapter-registry.cjs'); +// #3897 fixup: deriveCodexSandboxMode's 2nd param is now the already-resolved +// `tools:` frontmatter VALUE, not raw agent content (codex-agent-toml.cjs no +// longer parses frontmatter at all — no third copy of that extraction). +const { + extractFrontmatterAndBody: _extractFrontmatterAndBody, + extractFrontmatterField: _extractFrontmatterField, +} = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs'); +// #3897 list-form parse fix: the ONE shared `tools:`-value reader both +// sandbox-feeding production paths (`bin/install.js`'s `generateCodexAgentToml` +// and `agent-install-check.cts`'s `checkCodexSandboxPosture`) now route +// through — handles inline (`tools: Read, Write`) AND YAML block-list +// (`tools:` + indented `- Item` lines) form. Used below by `realAgentToolsRaw` +// so the test's own measurement of "what does this role's tool contract +// declare" cannot silently disagree with production (the exact generative- +// fix-divergence shape this fix closes). +const { extractToolsValue: _extractToolsValue } = require('../gsd-core/bin/lib/codex-agent-toml.cjs'); + +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 (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'); +} + +function _writeCodexConfig(codexHome, content) { + fs.mkdirSync(codexHome, { recursive: true }); + fs.writeFileSync(path.join(codexHome, 'config.toml'), content, 'utf8'); +} + +function _readHooksSessionStartCommands(codexHome) { + const hooksPath = path.join(codexHome, 'hooks.json'); + if (!fs.existsSync(hooksPath)) return []; + const raw = fs.readFileSync(hooksPath, 'utf8').trim(); + if (!raw) return []; + const parsed = JSON.parse(raw); + const table = (parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks)) + ? parsed.hooks + : parsed; + const sessionStart = Array.isArray(table.SessionStart) ? table.SessionStart : []; + return sessionStart.flatMap((entry) => [ + ...(typeof entry?.command === 'string' ? [entry.command] : []), + ...(Array.isArray(entry?.hooks) + ? entry.hooks.map((hook) => hook && hook.command).filter((cmd) => typeof cmd === 'string') + : []), + ]); +} + +function _countMatches(content, pattern) { + return (content.match(pattern) || []).length; +} + +function _assertNoDraftRootKeys(content) { + assert.ok(!content.includes('model = "gpt-5.6-terra"'), 'does not inject draft model default'); + assert.ok(!content.includes('model_reasoning_effort = "high"'), 'does not inject draft reasoning default'); + assert.ok(!content.includes('disable_response_storage = true'), 'does not inject draft storage default'); +} + +function _assertUsesOnlyEol(content, eol) { + if (eol === '\r\n') { + assert.ok(content.includes('\r\n'), 'contains CRLF line endings'); + assert.ok(!content.replace(/\r\r?\n/g, '').includes('\n'), 'does not contain bare LF line endings'); + return; + } + assert.ok(!content.includes('\r\n'), 'does not contain CRLF line endings'); +} + +function _assertNoCodexBareGsdToolsInvocation(content, label) { + const patterns = [ + /(^|\r?\n)[ \t]*gsd-tools\s/, + /\$\(\s*gsd-tools\s/, + /`\s*gsd-tools\s/, + /(?:&&|\|\||[;|])\s*gsd-tools\s/, + ]; + for (const pattern of patterns) { + assert.doesNotMatch( + content, + pattern, + `${label} must not contain a command-position bare gsd-tools invocation`, + ); + } +} + +// ─── getCodexSkillAdapterHeader ───────────────────────────────────────────────── + + + + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2760-codex-install-defensive.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2760-codex-install-defensive (consolidation epic #1969 B1 #1970)", () => { +/** + * Regression: issue #2760 — Codex install path corrupts existing config.toml. + * + * Three defects, three fixes (defensive triple): + * + * Defect 3 (confirmed real) — Hooks AoT downgrade. When the user already has + * `[[hooks.SessionStart]]` (namespaced AoT) entries in their config, GSD + * used to append a `[[hooks]]` (top-level AoT) block that confuses + * round-trip writers and produces a config Codex refuses to load. + * Fix: detect the user's preferred shape and emit GSD's hook in the same + * namespaced form so both coexist cleanly. + * + * Defects 1+2 (defensive) — Strip-step robustness. Pre-existing legacy + * `[agents]` (single-bracket) and `[[agents]]` (sequence) blocks are + * invalid in current Codex schema and break Codex even though GSD now + * emits the correct `[agents.]` struct form. Fix: install-time + * stripping always purges these forms regardless of GSD marker presence + * so reinstall self-heals files where the marker was edited out or never + * existed (third-party tools). + * + * Fix 3 (defensive) — Post-write validation. Parse the bytes we are about + * to commit, assert they match Codex's expected schema (no bare/sequence + * `agents`, no bare `hooks.`); on failure, restore the pre-install + * backup and abort so the user never gets a broken Codex CLI. + */ + +// Scope GSD_TEST_MODE to module load only — restore prior value (or unset) so +// downstream tests in the same node process never see test-only behaviour +// leak through (#2760 CR4 finding 5). +const previousGsdTestMode = process.env.GSD_TEST_MODE; +process.env.GSD_TEST_MODE = '1'; + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const { + install, + validateCodexConfigSchema, + hasUserNamespacedAotHooks, + parseTomlToObject, +} = require('../bin/install.js'); + +const { cleanup } = require('./helpers.cjs'); + +if (previousGsdTestMode === undefined) { + delete process.env.GSD_TEST_MODE; +} else { + process.env.GSD_TEST_MODE = previousGsdTestMode; +} + +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'); + } finally { + process.chdir(previousCwd); + 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; + } +} + +function readCodexConfig(codexHome) { + return fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8'); +} + +function writeCodexConfig(codexHome, content) { + fs.mkdirSync(codexHome, { recursive: true }); + fs.writeFileSync(path.join(codexHome, 'config.toml'), content, 'utf8'); +} + +function readCodexHooksJson(codexHome) { + const hooksPath = path.join(codexHome, 'hooks.json'); + if (!fs.existsSync(hooksPath)) return {}; + const raw = fs.readFileSync(hooksPath, 'utf8').trim(); + if (!raw) return {}; + return JSON.parse(raw); +} + +function readHooksSessionStartCommands(codexHome) { + const parsed = readCodexHooksJson(codexHome); + const table = (parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks)) + ? parsed.hooks + : parsed; + const sessionStart = Array.isArray(table.SessionStart) ? table.SessionStart : []; + return sessionStart.flatMap((entry) => + (Array.isArray(entry?.hooks) ? entry.hooks : []) + .map((hook) => hook && hook.command) + .filter((cmd) => typeof cmd === 'string') + ); +} + +describe('#2760 defect 3 — Hooks AoT preservation across install/uninstall/reinstall', () => { + let tmpDir; + let codexHome; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2760-d3-')); + codexHome = path.join(tmpDir, 'codex-home'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('fresh install emits the two-level nested AoT schema (#2773)', () => { + // Codex 0.124.0+ requires [[hooks.SessionStart]] + [[hooks.SessionStart.hooks]] + // with type = "command". Neither the flat [[hooks]] + event field form nor + // the single-block [[hooks.SessionStart]] form without .hooks is accepted. + writeCodexConfig(codexHome, ''); + runCodexInstall(codexHome); + const content = readCodexConfig(codexHome); + const parsed = parseTomlToObject(content); + + const sessionStartCommands = readHooksSessionStartCommands(codexHome); + const managed = sessionStartCommands.filter((cmd) => /gsd-check-update/.test(cmd)); + assert.equal(managed.length, 1, 'hooks.json must contain exactly one managed gsd-check-update command'); + assert.ok( + !parsed.hooks || !Array.isArray(parsed.hooks.SessionStart), + 'config.toml should not carry managed SessionStart hooks for GSD' + ); + }); + + test('preserves user [[hooks.SessionStart]] entries and registers managed GSD handler in hooks.json', () => { + // Users may have their own [[hooks.SessionStart]] entries using the new schema. + // GSD must append its own two-level block without disturbing theirs. + const userConfig = [ + '[[hooks.SessionStart]]', + '', + '[[hooks.SessionStart.hooks]]', + 'type = "command"', + 'command = "echo first user hook"', + '', + '[[hooks.SessionStart]]', + '', + '[[hooks.SessionStart.hooks]]', + 'type = "command"', + 'command = "echo second user hook"', + '', + ].join('\n'); + writeCodexConfig(codexHome, userConfig); + + runCodexInstall(codexHome); + const afterInstall = readCodexConfig(codexHome); + const parsed = parseTomlToObject(afterInstall); + + assert.ok( + parsed.hooks && Array.isArray(parsed.hooks.SessionStart), + 'hooks.SessionStart must remain an array-of-tables after install' + ); + + // Collect all handler commands across all event entries. + const allCommands = parsed.hooks.SessionStart.flatMap((entry) => + Array.isArray(entry.hooks) ? entry.hooks.map((h) => h.command) : [] + ); + + assert.ok( + allCommands.includes('echo first user hook'), + 'first user hook preserved: ' + JSON.stringify(allCommands) + ); + assert.ok( + allCommands.includes('echo second user hook'), + 'second user hook preserved: ' + JSON.stringify(allCommands) + ); + const hooksJsonCommands = readHooksSessionStartCommands(codexHome); + assert.ok( + hooksJsonCommands.some((cmd) => typeof cmd === 'string' && /gsd-check-update/.test(cmd)), + 'GSD handler must appear in hooks.json SessionStart entries: ' + JSON.stringify(hooksJsonCommands) + ); + assert.ok(!Array.isArray(parsed.hooks), 'no flat [[hooks]] entries'); + }); + + test('reinstall replaces flat [[hooks]] + event form with nested schema', () => { + // Upgrade path: user has a config written by GSD 1.38.x (flat [[hooks]] form). + const legacyConfig = [ + '[features]', + 'codex_hooks = true', + '', + '# GSD Hooks', + '[[hooks]]', + 'event = "SessionStart"', + 'command = "node /old/path/to/gsd-check-update.js"', + '', + ].join('\n'); + writeCodexConfig(codexHome, legacyConfig); + + runCodexInstall(codexHome); + const content = readCodexConfig(codexHome); + const parsed = parseTomlToObject(content); + + // Old flat form must be gone. + assert.ok(!Array.isArray(parsed.hooks), 'flat [[hooks]] must be stripped on upgrade'); + // Only one GSD hook entry must exist (no duplication) in hooks.json. + const hooksJsonCommands = readHooksSessionStartCommands(codexHome); + const gsdHandlers = hooksJsonCommands.filter((cmd) => /gsd-check-update/.test(cmd)); + assert.strictEqual(gsdHandlers.length, 1, 'exactly one managed handler after upgrade'); + }); + + test('reinstall replaces single-block [[hooks.SessionStart]] (no .hooks sub-table) with nested schema', () => { + // Upgrade path: user has a config written by the PR #2802 shape — + // [[hooks.SessionStart]] without a nested [[hooks.SessionStart.hooks]] sub-table. + const prBranchConfig = [ + '[features]', + 'codex_hooks = true', + '', + '# GSD Hooks', + '[[hooks.SessionStart]]', + 'command = "node /old/path/to/gsd-check-update.js"', + '', + ].join('\n'); + writeCodexConfig(codexHome, prBranchConfig); + + runCodexInstall(codexHome); + const content = readCodexConfig(codexHome); + parseTomlToObject(content); + + const hooksJsonCommands = readHooksSessionStartCommands(codexHome); + const gsdHandlers = hooksJsonCommands.filter((cmd) => /gsd-check-update/.test(cmd)); + assert.strictEqual(gsdHandlers.length, 1, 'exactly one managed handler after upgrade from PR-#2802-shape'); + }); + + test('reinstall is idempotent: correct nested schema is stripped and re-emitted cleanly', () => { + writeCodexConfig(codexHome, ''); + runCodexInstall(codexHome); + runCodexInstall(codexHome); // second install + readCodexConfig(codexHome); + + const hooksJsonCommands = readHooksSessionStartCommands(codexHome); + const gsdHandlers = hooksJsonCommands.filter((cmd) => /gsd-check-update/.test(cmd)); + assert.strictEqual(gsdHandlers.length, 1, 'exactly one managed SessionStart handler after double install'); + }); +}); + +describe('#2760 fix 2 — Strip purges invalid legacy [agents] / [[agents]] regardless of marker', () => { + let tmpDir; + let codexHome; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2760-f2-')); + codexHome = path.join(tmpDir, 'codex-home'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('strips bare [agents] single-bracket block (no GSD marker, arbitrary user keys)', () => { + writeCodexConfig(codexHome, [ + '[agents]', + 'default = "custom-agent"', + 'extra_key = "value"', + '', + '[model]', + 'name = "o3"', + '', + ].join('\n')); + + runCodexInstall(codexHome); + const content = readCodexConfig(codexHome); + const parsed = parseTomlToObject(content); + + // Bare [agents] would have left { default, extra_key } as scalar leaves + // on parsed.agents. After strip + re-emit, only GSD's own managed + // AgentsToml scalar (max_depth) remains — #2406 stopped emitting + // [agents.] role sub-tables entirely, so `agents` stays a flat + // scalar-only object, not a table-of-tables. + assert.ok( + parsed.agents && typeof parsed.agents === 'object' && !Array.isArray(parsed.agents), + 'agents must be an object in parsed structure, got: ' + typeof parsed.agents + ); + assert.equal(parsed.agents.default, undefined, 'bare [agents] default key must be stripped'); + assert.equal(parsed.agents.extra_key, undefined, 'bare [agents] extra_key must be stripped'); + assert.equal(parsed.agents.max_depth, 1, 'GSD-managed max_depth is the only surviving [agents] key'); + const gsdAgents = Object.keys(parsed.agents).filter((k) => k.startsWith('gsd-')); + assert.deepStrictEqual( + gsdAgents, [], + 'no [agents.gsd-*] role sub-tables (#2406) — canonical registration lives only in the standalone TOMLs: ' + JSON.stringify(Object.keys(parsed.agents)) + ); + + // User's unrelated [model] section preserved structurally. + assert.ok( + parsed.model && parsed.model.name === 'o3', + 'unrelated user [model] section preserved with name = "o3", got: ' + JSON.stringify(parsed.model) + ); + }); + + test('strips [[agents]] sequence-form block without GSD marker (third-party / marker-edited-out)', () => { + writeCodexConfig(codexHome, [ + '[[agents]]', + 'name = "user-helper"', + 'description = "third-party agent"', + '', + '[[agents]]', + 'name = "another-helper"', + 'description = "second one"', + '', + '[projects."/tmp/x"]', + 'trust_level = "trusted"', + '', + ].join('\n')); + + runCodexInstall(codexHome); + const content = readCodexConfig(codexHome); + const parsed = parseTomlToObject(content); + + // [[agents]] sequence form would parse to Array — after strip it must be + // a plain object holding only GSD's own managed max_depth scalar (#2406 + // stopped emitting [agents.] role sub-tables entirely). + assert.ok( + parsed.agents && typeof parsed.agents === 'object' && !Array.isArray(parsed.agents), + 'agents must be an object in parsed structure (sequence form must be stripped), got: ' + + (Array.isArray(parsed.agents) ? 'array' : typeof parsed.agents) + ); + assert.equal(parsed.agents.max_depth, 1, 'GSD-managed max_depth is the only surviving [agents] key'); + const gsdAgents = Object.keys(parsed.agents).filter((k) => k.startsWith('gsd-')); + assert.deepStrictEqual( + gsdAgents, [], + 'no [agents.gsd-*] role sub-tables (#2406) — canonical registration lives only in the standalone TOMLs: ' + JSON.stringify(Object.keys(parsed.agents)) + ); + + // User's unrelated [projects."/tmp/x"] section preserved structurally. + assert.ok( + parsed.projects && parsed.projects['/tmp/x'] && parsed.projects['/tmp/x'].trust_level === 'trusted', + 'unrelated user [projects."/tmp/x"] section preserved with trust_level = "trusted", got: ' + + JSON.stringify(parsed.projects) + ); + }); +}); + +// concurrency: false — the third test mutates installModule.__codexSchemaValidator, +// a module-level test seam. Other tests in this file (and in bug-2153, etc.) +// also call runCodexInstall() and would observe the injected validator if +// node:test ran them in parallel. Serializing this describe block keeps the +// seam mutation invisible to siblings. +describe('#2760 fix 3 — Post-write Codex schema validation', { concurrency: false }, () => { + test('passes a clean config produced by GSD install', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2760-f3a-')); + try { + const codexHome = path.join(tmpDir, 'codex-home'); + runCodexInstall(codexHome); + const content = readCodexConfig(codexHome); + const result = validateCodexConfigSchema(content); + assert.equal(result.ok, true, 'GSD-emitted config passes schema validation'); + } finally { + cleanup(tmpDir); + } + }); + + test('rejects bare [agents] and bare [hooks.SessionStart] in arbitrary content', () => { + const bareAgents = [ + '[agents]', + 'default = "x"', + '', + ].join('\n'); + const bareHooks = [ + '[hooks.SessionStart]', + 'command = "x"', + '', + ].join('\n'); + const sequenceAgents = [ + '[[agents]]', + 'name = "x"', + '', + ].join('\n'); + + assert.equal(validateCodexConfigSchema(bareAgents).ok, false, 'bare [agents] rejected'); + assert.equal(validateCodexConfigSchema(bareHooks).ok, false, 'bare [hooks.SessionStart] rejected'); + assert.equal(validateCodexConfigSchema(sequenceAgents).ok, false, '[[agents]] sequence rejected'); + }); + + test('aborts install and restores pre-install backup when post-write validation fails', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2760-f3b-')); + const installModule = require('../bin/install.js'); + try { + const codexHome = path.join(tmpDir, 'codex-home'); + // Pre-install file the user wants protected. + const preInstall = [ + '# user file', + '[model]', + 'name = "o3"', + '', + ].join('\n'); + writeCodexConfig(codexHome, preInstall); + + // Force the post-write validator to fail via the documented test seam. + // This simulates the writer producing legacy-form output that Codex + // would reject — install MUST abort, restore the pre-install bytes, + // and surface a clear error. + installModule.__codexSchemaValidator = () => ({ + ok: false, + reason: 'simulated invalid output for test', + }); + + let threw = false; + try { + runCodexInstall(codexHome); + } catch (e) { + threw = true; + assert.match( + e.message, + /post-write Codex schema validation failed/, + 'thrown error names the validation failure' + ); + assert.match(e.message, /simulated invalid output for test/, 'thrown error includes reason'); + } + assert.equal(threw, true, 'install threw when validator failed'); + + const afterInstall = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8'); + assert.equal( + afterInstall, + preInstall, + 'pre-install file restored verbatim after validation failure' + ); + } finally { + delete installModule.__codexSchemaValidator; + cleanup(tmpDir); + } + }); +}); + +describe('#2760 — hasUserNamespacedAotHooks helper', () => { + test('detects [[hooks.SessionStart]] AoT entries', () => { + const content = [ + '[[hooks.SessionStart]]', + 'command = "x"', + '', + ].join('\n'); + assert.equal(hasUserNamespacedAotHooks(content, 'SessionStart'), true); + }); + + test('returns false when only top-level [[hooks]] entries exist', () => { + const content = [ + '[[hooks]]', + 'event = "SessionStart"', + 'command = "x"', + '', + ].join('\n'); + assert.equal(hasUserNamespacedAotHooks(content, 'SessionStart'), false); + }); + + test('returns false when only single-bracket [hooks.SessionStart] exists', () => { + const content = [ + '[hooks.SessionStart]', + 'command = "x"', + '', + ].join('\n'); + assert.equal(hasUserNamespacedAotHooks(content, 'SessionStart'), false); + }); +}); + +// concurrency: false — these tests monkey-patch fs.writeFileSync, a global +// shared with every other suite running in parallel. Serializing prevents +// stray writes from sibling tests landing in the stub. +describe('#2760 fix 4 — Write-failure rollback (atomic write + snapshot restore)', { concurrency: false }, () => { + let tmpDir; + let codexHome; + let originalWriteFileSync; + // #2760 CR5 finding 5 — symmetric snapshot/restore for fs.renameSync. The + // first test below monkey-patches renameSync; without a beforeEach/afterEach + // pair, only the local `finally` restores it, which is fragile to future + // edits that add early-return paths. + let originalRenameSync; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2760-f4-')); + codexHome = path.join(tmpDir, 'codex-home'); + originalWriteFileSync = fs.writeFileSync; + originalRenameSync = fs.renameSync; + }); + + afterEach(() => { + fs.renameSync = originalRenameSync; + fs.writeFileSync = originalWriteFileSync; + cleanup(tmpDir); + }); + + test('pre-install config bytes survive when fs.renameSync throws over configPath', () => { + const preInstall = [ + '# user file', + '[model]', + 'name = "o3"', + '', + ].join('\n'); + writeCodexConfig(codexHome, preInstall); + + // After fs is restored we'll re-read the file. Capture the byte buffer + // exactly so the comparison is bit-for-bit. + const preInstallBytes = fs.readFileSync(path.join(codexHome, 'config.toml')); + + const configPath = path.join(codexHome, 'config.toml'); + const tempPattern = new RegExp('^' + escapeRegex(configPath) + '\\.tmp-'); + + // Stub: allow writes to atomic temp files (which renameSync overwrites + // the target, never truncating it directly) but throw on any direct + // write to the canonical configPath. This simulates either: + // (a) an older code path doing a non-atomic write, or + // (b) a downstream module bypassing atomicWriteFileSync. + // Either way the snapshot must be restored. We let the temp write go + // through, then make renameSync throw to simulate the partial write + // never landing. + // #2760 CR5 finding 5 — fs.renameSync is restored by the suite-level + // afterEach; no local finally needed. + fs.renameSync = (src, dst) => { + if (dst === configPath) { + throw new Error('simulated rename failure mid-install'); + } + return originalRenameSync(src, dst); + }; + + let threw = false; + let thrownErr = null; + try { + runCodexInstall(codexHome); + } catch (e) { + threw = true; + thrownErr = e; + assert.ok(/rename failure|simulated|post-write/.test(e.message), + 'thrown error must surface the simulated failure or its post-write wrapper: ' + e.message); + } + // #2760 CR5 finding 4 — tighten contract per finding #1: ALL pre-write + // and write failures must be fatal. This test previously accepted either + // throw OR warn — sibling tests already require throw, so lock parity. + assert.equal(threw, true, 'rename failure must be fatal: ' + (thrownErr && thrownErr.message)); + + const afterBytes = fs.readFileSync(path.join(codexHome, 'config.toml')); + assert.deepStrictEqual( + afterBytes, + preInstallBytes, + 'pre-install config.toml bytes must survive a mid-install write/rename failure' + ); + + // And the parsed structure of the surviving file must still be the + // user's [model] section, not a half-written GSD block. + const parsed = parseTomlToObject(afterBytes.toString('utf8')); + assert.equal(parsed.model && parsed.model.name, 'o3', + 'surviving file must still be the user pre-install content'); + assert.equal(parsed.agents, undefined, + 'no GSD agents block may have leaked into the surviving file'); + + // No stray .tmp-* siblings left behind in the codex home. + const stray = fs.readdirSync(codexHome).filter((f) => tempPattern.test(path.join(codexHome, f))); + assert.equal(stray.length, 0, + 'atomic write must clean up its temp file on failure: ' + stray.join(', ')); + }); + + test('pre-install config bytes survive when fs.writeFileSync throws on the .tmp- target', () => { + const preInstall = [ + '# user file', + '[model]', + 'name = "o3"', + '', + ].join('\n'); + writeCodexConfig(codexHome, preInstall); + + const preInstallBytes = fs.readFileSync(path.join(codexHome, 'config.toml')); + const configPath = path.join(codexHome, 'config.toml'); + const tempPattern = new RegExp('^' + escapeRegex(configPath) + '\\.tmp-'); + + // Stub: fault writes targeting the atomic temp file (the pre-rename branch + // of atomicWriteFileSync). Other writes (agent .toml files in CODEX_HOME) + // pass through. This exercises the failure path where the temp write itself + // throws, not the rename — the case the prior test left untested. + // #2760 CR5 finding 5 — fs.writeFileSync is restored by the suite-level + // afterEach (via originalWriteFileSync); no local finally needed. + const captured = originalWriteFileSync; + fs.writeFileSync = function patchedWriteFileSync(target, data, options) { + if (typeof target === 'string' && tempPattern.test(target)) { + throw new Error('simulated writeFileSync failure on .tmp- target'); + } + return captured.call(this, target, data, options); + }; + + let threw = false; + try { + runCodexInstall(codexHome); + } catch (e) { + threw = true; + assert.ok(/simulated writeFileSync failure|post-write Codex install failed|pre-write/.test(e.message), + 'thrown error must surface the simulated failure or its post-write wrapper: ' + e.message); + } + // Per #2760 CR4 finding 1 / CR5 finding 1, write failures must abort install (not warn). + assert.equal(threw, true, 'install must throw when atomic temp-write fails'); + + const afterBytes = fs.readFileSync(path.join(codexHome, 'config.toml')); + assert.deepStrictEqual( + afterBytes, + preInstallBytes, + 'pre-install config.toml bytes must survive a temp-write failure' + ); + + const parsed = parseTomlToObject(afterBytes.toString('utf8')); + assert.equal(parsed.model && parsed.model.name, 'o3', + 'surviving file must still be the user pre-install content'); + assert.equal(parsed.agents, undefined, + 'no GSD agents block may have leaked into the surviving file'); + + const stray = fs.readdirSync(codexHome).filter((f) => tempPattern.test(path.join(codexHome, f))); + assert.equal(stray.length, 0, + 'atomic write must clean up its temp file on failure: ' + stray.join(', ')); + }); +}); + +// concurrency: false — these tests rely on the same install path and module- +// level pre-install snapshot that the fix-3/fix-4 suites exercise. Serializing +// keeps state mutations from leaking across parallel siblings. +describe('#2760 CR4 finding 2 — Legacy flat [[hooks]] block migrates to namespaced AoT on reinstall', { concurrency: false }, () => { + let tmpDir; + let codexHome; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2760-cr4-f2-')); + codexHome = path.join(tmpDir, 'codex-home'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('pre-install legacy flat [[hooks]] gsd-check-update + user namespaced [[hooks.SessionStart]] → post-install converges on namespaced AoT', () => { + // Reproduce the upgrade scenario: + // - User has [[hooks.SessionStart]] entry of their own (signal that GSD + // should emit in the namespaced shape). + // - A previous GSD install left the legacy flat [[hooks]] managed block + // for gsd-check-update. The pre-CR4 strip step would short-circuit + // the namespaced emit and leave the user stuck in the mixed layout. + const userPlusLegacy = [ + '[[hooks.SessionStart]]', + 'command = "echo user hook"', + '', + '# GSD Hooks', + '[[hooks]]', + 'event = "SessionStart"', + 'command = "node /old/path/hooks/gsd-check-update.js"', + '', + ].join('\n'); + writeCodexConfig(codexHome, userPlusLegacy); + + runCodexInstall(codexHome); + const afterInstall = readCodexConfig(codexHome); + const parsed = parseTomlToObject(afterInstall); + + // After CR4 finding 2: the legacy flat [[hooks]] managed block is stripped + // and the GSD entry is re-emitted in the namespaced AoT shape so the two + // forms do not coexist. + assert.ok( + parsed.hooks && Array.isArray(parsed.hooks.SessionStart), + 'hooks.SessionStart must be an array-of-tables, got: ' + + (parsed.hooks ? typeof parsed.hooks.SessionStart : 'no hooks table') + ); + + // Migration now handles stale [[hooks.SessionStart]] entries with handler + // fields at event-entry level (pre-#2773 shape), promoting them to the + // two-level nested form. Every entry must carry a .hooks sub-array after + // migration, so collect from nested handlers only. + assert.ok( + parsed.hooks.SessionStart.every((entry) => Array.isArray(entry.hooks)), + 'every hooks.SessionStart entry must use nested [[hooks.SessionStart.hooks]] handlers after migration' + ); + const allSessionStartCommands = parsed.hooks.SessionStart.flatMap((entry) => + entry.hooks.map((h) => h.command).filter(Boolean) + ); + assert.ok( + allSessionStartCommands.includes('echo user hook'), + 'user [[hooks.SessionStart]] entry preserved: ' + JSON.stringify(allSessionStartCommands) + ); + const hooksJsonCommands = readHooksSessionStartCommands(codexHome); + assert.ok( + hooksJsonCommands.some((cmd) => typeof cmd === 'string' && /gsd-check-update/.test(cmd)), + 'GSD entry must appear in hooks.json SessionStart entries: ' + + JSON.stringify(hooksJsonCommands) + ); + + // The legacy top-level [[hooks]] AoT must NOT coexist with the namespaced + // form after migration. parseTomlToObject distinguishes via Array.isArray. + assert.ok( + !Array.isArray(parsed.hooks) || parsed.hooks.length === 0, + 'no top-level [[hooks]] AoT entries may remain after legacy migration: ' + + JSON.stringify(parsed.hooks) + ); + + // No duplicate gsd-check-update entries — exactly one managed entry. + const gsdEntries = hooksJsonCommands.filter((cmd) => typeof cmd === 'string' && /gsd-check-update/.test(cmd)); + assert.equal(gsdEntries.length, 1, + 'exactly one gsd-check-update entry after migration, got: ' + gsdEntries.length); + }); +}); + +describe('#2760 CR4 finding 3 / #3245 — parseTomlToObject handles edge-case value types (floats accepted; dates/trailing-garbage rejected)', () => { + // #3245 inverts the float-rejection requirement: Codex CLI's serde schema + // requires f64 for tool_timeout_sec/startup_timeout_sec, so GSD's parser + // must now ACCEPT floats. The original guard (from #2760 CR4 finding 3) was + // "don't silently truncate 0.5 to integer 0" — that goal is still met + // because we parse the full float as a JS Number (not truncate to prefix). + test('accepts TOML floats (timeout = 0.5) — #3245 fix', () => { + const content = [ + '[server]', + 'timeout = 0.5', + '', + ].join('\n'); + const parsed = parseTomlToObject(content); + assert.strictEqual(parsed.server.timeout, 0.5, + 'float values must be accepted as JS Number (not truncated to 0) — #3245'); + }); + + test('rejects date values (created = 1979-05-27)', () => { + const content = [ + '[meta]', + 'created = 1979-05-27', + '', + ].join('\n'); + assert.throws( + () => parseTomlToObject(content), + /unsupported TOML value|trailing bytes/, + 'date values must be rejected, not silently truncated' + ); + }); + + test('rejects trailing garbage after a string value (key = "x" junk)', () => { + const content = [ + '[section]', + 'key = "x" junk', + '', + ].join('\n'); + assert.throws( + () => parseTomlToObject(content), + /trailing bytes/, + 'trailing bytes after a complete value must be rejected' + ); + }); + + test('accepts trailing whitespace and # comment after a value', () => { + const content = [ + '[section]', + 'key = "x" # an inline comment', + 'flag = true', + 'count = 7 ', + '', + ].join('\n'); + const parsed = parseTomlToObject(content); + assert.equal(parsed.section.key, 'x'); + assert.equal(parsed.section.flag, true); + assert.equal(parsed.section.count, 7); + }); +}); + +// concurrency: false — see the fix-3 suite above for the same rationale. +describe('#2760 CR4 finding 1 — atomicWriteFileSync failure aborts install (post-write fatal)', { concurrency: false }, () => { + let tmpDir; + let codexHome; + let originalRenameSync; + let originalConsoleLog; + let consoleOutput; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2760-cr4-f1-')); + codexHome = path.join(tmpDir, 'codex-home'); + originalRenameSync = fs.renameSync; + originalConsoleLog = console.log; + consoleOutput = []; + console.log = (...args) => { consoleOutput.push(args.join(' ')); }; + }); + + afterEach(() => { + fs.renameSync = originalRenameSync; + console.log = originalConsoleLog; + cleanup(tmpDir); + }); + + test('install throws and never prints "Done!" when atomicWriteFileSync fails on configPath', () => { + const preInstall = [ + '# user file', + '[model]', + 'name = "o3"', + '', + ].join('\n'); + writeCodexConfig(codexHome, preInstall); + + const configPath = path.join(codexHome, 'config.toml'); + // Only fault the hook-block atomic rename — earlier writes to config.toml + // happen via mergeCodexConfig (agent-block emit). We want to exercise the + // post-write Codex install branch specifically. Detect by reading the temp + // file's contents and only faulting when the hook block is present. + fs.renameSync = (src, dst) => { + if (dst === configPath) { + let isHookWrite = false; + try { + const data = fs.readFileSync(src, 'utf8'); + isHookWrite = /GSD codex_hooks ownership/.test(data); + } catch (_) { /* ignore */ } + if (isHookWrite) { + throw new Error('simulated rename failure'); + } + } + return originalRenameSync(src, dst); + }; + + let threw = false; + let thrownMessage = ''; + try { + runCodexInstall(codexHome); + } catch (e) { + threw = true; + thrownMessage = e.message; + } + + assert.equal(threw, true, 'install must throw when atomic write fails'); + assert.match( + thrownMessage, + /post-write Codex install failed/, + 'thrown error must use the post-write prefix so the outer catch treats it as fatal' + ); + + // Critical: install must NOT have printed any "Done!" success banner. + const printedDone = consoleOutput.some( + (line) => typeof line === 'string' && /Done!/i.test(line) + ); + assert.equal(printedDone, false, + 'install must NOT print "Done!" after a write failure: ' + JSON.stringify(consoleOutput.filter((l) => /Done|✓/.test(l)))); + + // And the user's pre-install bytes are intact (snapshot restore). + const after = fs.readFileSync(configPath, 'utf8'); + assert.equal(after, preInstall, 'pre-install bytes preserved after fatal abort'); + }); +}); + +// concurrency: false — patches module.exports.__codexSchemaValidator, a +// shared test seam. Serializing prevents stray patches from sibling tests. +describe('#2760 CR5 finding 1 — pre-write failures abort install (outer catch fatal)', { concurrency: false }, () => { + let tmpDir; + let codexHome; + let originalConsoleLog; + let consoleOutput; + const installModule = require('../bin/install.js'); + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2760-cr5-f1-')); + codexHome = path.join(tmpDir, 'codex-home'); + originalConsoleLog = console.log; + consoleOutput = []; + console.log = (...args) => { consoleOutput.push(args.join(' ')); }; + }); + + afterEach(() => { + console.log = originalConsoleLog; + delete installModule.__codexSchemaValidator; + cleanup(tmpDir); + }); + + test('pre-write throw (validator throws, not returns {ok:false}) is fatal and restores snapshot', () => { + // A validator that THROWS (vs returning {ok:false}) bypasses the + // validation branch and exits the inner try via the catch at the outer + // level. Pre-CR5, that catch downgraded to console.warn and let the + // install print "Done!" with no Codex hooks. Post-CR5 it must rethrow. + const preInstall = [ + '# user file', + '[model]', + 'name = "o3"', + '', + ].join('\n'); + writeCodexConfig(codexHome, preInstall); + + installModule.__codexSchemaValidator = () => { + throw new Error('synthetic validator-throw simulating a pre-write helper failure'); + }; + + let threw = false; + let thrownMsg = ''; + try { + runCodexInstall(codexHome); + } catch (e) { + threw = true; + thrownMsg = e.message; + } + + assert.equal(threw, true, + 'install must rethrow when a pre-write step throws (CR5 finding 1)'); + assert.match(thrownMsg, /pre-write|synthetic validator-throw/, + 'thrown error must surface the pre-write wrapper or original message: ' + thrownMsg); + + const printedDone = consoleOutput.some( + (line) => typeof line === 'string' && /Done!/i.test(line) + ); + assert.equal(printedDone, false, + 'install must NOT print "Done!" after a pre-write failure: ' + + JSON.stringify(consoleOutput.filter((l) => /Done|✓/.test(l)))); + + // Pre-install bytes intact (snapshot restored). + const after = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8'); + assert.equal(after, preInstall, + 'pre-install bytes must survive a pre-write helper throw'); + }); +}); + +describe('#2760 CR5 finding 2 — parseTomlToObject rejects duplicate keys and shape-mismatched headers', () => { + test('rejects duplicate scalar key in same table ([a]\\nx=1\\nx=2)', () => { + const content = [ + '[a]', + 'x = 1', + 'x = 2', + '', + ].join('\n'); + assert.throws( + () => parseTomlToObject(content), + /duplicate key/, + 'real TOML 1.0 rejects duplicate keys in the same table' + ); + }); + + test('rejects duplicate scalar key in root table', () => { + const content = [ + 'x = 1', + 'x = 2', + '', + ].join('\n'); + assert.throws( + () => parseTomlToObject(content), + /duplicate key/, + 'duplicate root-table keys must be rejected' + ); + }); + + test('rejects re-declared [a] table header ([a] then [a] again)', () => { + const content = [ + '[a]', + 'x = 1', + '', + '[a]', + 'y = 2', + '', + ].join('\n'); + assert.throws( + () => parseTomlToObject(content), + /duplicate or shape-mismatched table header/, + 'real TOML 1.0 rejects re-declaring the same [a] header twice' + ); + }); + + test('rejects [[arr]] then [arr] for same path (array-of-tables → table)', () => { + const content = [ + '[[arr]]', + 'x = 1', + '', + '[arr]', + 'y = 2', + '', + ].join('\n'); + assert.throws( + () => parseTomlToObject(content), + /duplicate or shape-mismatched table header/, + 'cannot redeclare an array-of-tables path as a plain table' + ); + }); + + test('accepts repeated [[arr]] (genuine array-of-tables)', () => { + const content = [ + '[[arr]]', + 'x = 1', + '', + '[[arr]]', + 'x = 2', + '', + ].join('\n'); + const parsed = parseTomlToObject(content); + assert.ok(Array.isArray(parsed.arr)); + assert.strictEqual(parsed.arr.length, 2); + assert.strictEqual(parsed.arr[0].x, 1); + assert.strictEqual(parsed.arr[1].x, 2); + }); + + test('accepts disjoint nested headers (not duplicates)', () => { + const content = [ + '[a.b]', + 'x = 1', + '', + '[a.c]', + 'y = 2', + '', + ].join('\n'); + const parsed = parseTomlToObject(content); + assert.strictEqual(parsed.a.b.x, 1); + assert.strictEqual(parsed.a.c.y, 2); + }); +}); + +// concurrency: false — drives the same install pipeline as the other f-suites. +describe('#2760 CR5 finding 3 — migration emits namespaced AoT (no flat/namespaced mixing)', { concurrency: false }, () => { + let tmpDir; + let codexHome; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2760-cr5-f3-')); + codexHome = path.join(tmpDir, 'codex-home'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('user has [[hooks.AfterTool]] AND legacy [hooks.SessionStart] → post-install both namespaced, no flat AoT', () => { + // Reproduces the mixed-form scenario from finding 3: + // - User pre-config has both a namespaced AoT entry [[hooks.AfterTool]] + // AND a legacy single-bracket [hooks.SessionStart]. + // - Pre-CR5 migration converts the legacy section to flat [[hooks]] + // with event="SessionStart", leaving a mixed flat+namespaced layout. + // - Post-CR5 migration emits [[hooks.SessionStart]] directly so both + // of the user's hooks coexist in the namespaced shape, and the + // GSD-managed entry converges on namespaced too. + const userPlusLegacy = [ + '[[hooks.AfterTool]]', + 'command = "x"', + '', + '[hooks.SessionStart]', + 'command = "y"', + '', + ].join('\n'); + writeCodexConfig(codexHome, userPlusLegacy); + + runCodexInstall(codexHome); + const after = readCodexConfig(codexHome); + const parsed = parseTomlToObject(after); + + // The pre-existing [[hooks.AfterTool]] entry is preserved. + assert.ok( + parsed.hooks && Array.isArray(parsed.hooks.AfterTool), + 'pre-existing [[hooks.AfterTool]] must remain a namespaced AoT array' + ); + // AfterTool was in [[hooks.AfterTool]] with command at event-entry level + // (pre-#2773 stale namespaced AoT shape). Migration now promotes these to + // the two-level nested form, so every entry must have a .hooks sub-array. + assert.ok( + parsed.hooks.AfterTool.every((e) => Array.isArray(e.hooks)), + 'every AfterTool entry must use nested [[hooks.AfterTool.hooks]] handlers after migration' + ); + const afterToolCommands = parsed.hooks.AfterTool.flatMap((e) => + e.hooks.map((h) => h.command).filter(Boolean) + ); + assert.ok( + afterToolCommands.includes('x'), + 'user AfterTool entry must be preserved: ' + JSON.stringify(afterToolCommands) + ); + + // The migrated SessionStart entry is now namespaced AoT with nested .hooks sub-table. + assert.ok( + parsed.hooks && Array.isArray(parsed.hooks.SessionStart), + 'migrated SessionStart must be namespaced AoT (not flat [[hooks]])' + ); + // After migration, [hooks.SessionStart] map-format is promoted to nested AoT. + // Command lives in [[hooks.SessionStart.hooks]][0].command (nested schema). + assert.ok( + parsed.hooks.SessionStart.every((e) => Array.isArray(e.hooks)), + 'every SessionStart entry must use nested [[hooks.SessionStart.hooks]] handlers after migration' + ); + const ssCommands = parsed.hooks.SessionStart.flatMap((e) => + e.hooks.map((h) => h.command).filter(Boolean) + ); + assert.ok( + ssCommands.includes('y'), + 'user SessionStart command "y" must be preserved in namespaced array: ' + + JSON.stringify(ssCommands) + ); + // GSD's managed gsd-check-update entry also lives in the namespaced array. + const hooksJsonCommands = readHooksSessionStartCommands(codexHome); + assert.ok( + hooksJsonCommands.some((cmd) => typeof cmd === 'string' && /gsd-check-update/.test(cmd)), + 'managed gsd-check-update entry must appear in hooks.json SessionStart entries: ' + + JSON.stringify(hooksJsonCommands) + ); + + // No flat top-level [[hooks]] AoT may remain. + assert.ok( + !Array.isArray(parsed.hooks) || parsed.hooks.length === 0, + 'no flat top-level [[hooks]] AoT entries may remain after migration: ' + + JSON.stringify(parsed.hooks) + ); + + // No synthetic event field on the migrated SessionStart entries — the + // namespace IS the event. + for (const entry of parsed.hooks.SessionStart) { + assert.equal(entry.event, undefined, + 'no synthetic event field — namespace [[hooks.SessionStart]] encodes the event: ' + + JSON.stringify(entry)); + } + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-279-codex-agent-mapping.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-279-codex-agent-mapping (consolidation epic #1969 B1 #1970)", () => { +'use strict'; +// allow-test-rule: source-text-is-the-product [adapter header contract in bin/install.js] (see #279) + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const INSTALL_JS = path.join(__dirname, '..', 'bin', 'install.js'); +const src = fs.readFileSync(INSTALL_JS, 'utf8'); + +describe('bug #279: Codex adapter documents Agent() and deferred tool discovery', () => { + test('adapter mapping section includes explicit Agent(...) -> spawn_agent mapping', () => { + // allow-test-rule: source-text-is-the-product [adapter header contract in bin/install.js] (see #279) + assert.ok( + /Task\(subagent_type="X", prompt="Y"\).*spawn_agent\(agent_type="X", message="Y"\)/.test(src) && // allow-test-rule: source-text-is-the-product [adapter header contract in bin/install.js] (see #279) + /Agent\(subagent_type="X", prompt="Y"\).*spawn_agent\(agent_type="X", message="Y"\)/.test(src), // allow-test-rule: source-text-is-the-product [adapter header contract in bin/install.js] (see #279) + 'Codex adapter must explicitly map both Task(...) and Agent(...) to spawn_agent', + ); + }); + + test('adapter includes deferred tool_search discovery guidance before inline fallback', () => { + // allow-test-rule: source-text-is-the-product [adapter header contract in bin/install.js] (see #279) + assert.ok( + src.includes('deferred') && src.includes('tool_search') && src.includes('spawn_agent'), // allow-test-rule: source-text-is-the-product [adapter header contract in bin/install.js] (see #279) + 'Codex adapter must instruct deferred tool discovery via tool_search before deciding to run inline', + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3017-codex-hook-absolute-node.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3017-codex-hook-absolute-node (consolidation epic #1969 B1 #1970)", () => { +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +/** + * Bug #3017: Codex SessionStart hook still emits bare `node` after #3002. + * + * PR #3002 fixed #2979 for settings.json-based managed JS hooks (Claude + * Code, Gemini, Antigravity) by routing through buildHookCommand() → + * resolveNodeRunner(), which emits the absolute Node binary path. But the + * Codex install path writes its SessionStart hook directly into a + * config.toml string, bypassing both helpers: + * + * command = "node ${updateCheckScript}" + * + * Under a GUI/minimal PATH (`/usr/bin:/bin:/usr/sbin:/sbin`) where node + * is not resolvable, the hook fails with `/bin/sh: node: command not + * found` (exit 127). The same failure mode #2979 was meant to fix — + * just on the codex toml branch instead of the settings.json branch. + * + * The fix exposes two pure helpers and tests them as typed records, + * not by grepping install.js content: + * + * buildCodexHookBlock(targetDir, { absoluteRunner }) → toml string + * - emits `command = " "` so the + * hook resolves under minimal PATH. + * - returns null when absoluteRunner is null (caller skips with warn, + * matching settings.json branch behavior). + * + * rewriteLegacyCodexHookBlock(tomlContent, absoluteRunner) → { content, changed } + * - rewrites an existing bare-node managed-hook command on reinstall + * (matches the rewriteLegacyManagedNodeHookCommands shape from #3002). + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); + +const HOOKS_SURFACE = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'runtime-hooks-surface.cjs')); +const projection = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'shell-command-projection.cjs')); +const { buildCodexHookBlock, rewriteLegacyCodexHookBlock, resolveNodeRunner } = HOOKS_SURFACE; +const { projectCodexHookTomlCommand } = projection; + +/** + * Parse the toml hook block into a typed record so tests can assert on + * the structured shape (what's the runner, what's the hook path, what's + * the type) rather than substring-matching the toml text. + */ +function parseCodexHookBlock(block) { + if (!block) return { ok: false, reason: 'empty' }; + // The block always carries the "# GSD Hooks" marker, the AoT tables, + // a type=command, and a command=" " line. + const hasMarker = /^# GSD Hooks$/m.test(block); + const hasEvent = /^\[\[hooks\.SessionStart\]\]$/m.test(block); + const hasHandler = /^\[\[hooks\.SessionStart\.hooks\]\]$/m.test(block); + const typeMatch = block.match(/^type\s*=\s*"([^"]+)"$/m); + // command = " " — runner may itself be a quoted absolute path. + // Match the whole RHS as one toml double-quoted string, then split into runner + hookpath. + const cmdLine = block.match(/^command\s*=\s*"((?:[^"\\]|\\.)*)"$/m); + if (!cmdLine) return { ok: false, reason: 'no command line' }; + const cmdValue = cmdLine[1]; + // Inside the command value, the runner is either a quoted string (escaped \" in toml) + // or a bare token, followed by a space and the hook path (quoted). + // toml escapes interior " as \", so the cmdValue contains literal \" sequences. + const cmdParsed = cmdValue.match(/^(\\".+?\\"|node|bash|\S+)\s+\\"([^\\]+)\\"\s*$/); + return { + ok: true, + hasMarker, + hasEvent, + hasHandler, + type: typeMatch ? typeMatch[1] : null, + command: cmdValue, + runner: cmdParsed ? cmdParsed[1] : null, + hookPath: cmdParsed ? cmdParsed[2] : null, + }; +} + +// Strip the toml-escape (\") and JSON-quote (") layers from the parsed +// runner token to compare against the raw absolute path the caller +// supplied. parsed.runner round-trips through TWO escape layers: +// 1. JSON.stringify in resolveNodeRunner adds outer "..." quotes +// 2. toml escapes the interior " to \" inside the command field +// After both, parsed.runner ends in `\"` and starts with `\"`. +function unescapeRunner(token) { + if (!token) return token; + let t = token.replace(/^\\"/, '').replace(/\\"$/, ''); + if (t.startsWith('"') && t.endsWith('"')) t = t.slice(1, -1); + return t; +} + +describe('Bug #3017 / #3440: Codex hook projection seam', () => { + test('projectCodexHookTomlCommand renders escaped command value from shared projection module', () => { + const commandValue = projectCodexHookTomlCommand({ + absoluteRunner: '"/usr/local/bin/node"', + scriptPath: '/tmp/codex-test/.codex/hooks/gsd-check-update.js', + platform: 'linux', + }); + assert.equal( + commandValue, + '\\"/usr/local/bin/node\\" \\"/tmp/codex-test/.codex/hooks/gsd-check-update.js\\"', + ); + }); +}); + +describe('Bug #3017: buildCodexHookBlock emits absolute node runner', () => { + test('exported as a function', () => { + assert.equal(typeof buildCodexHookBlock, 'function'); + }); + + test('emits the EXACT absolute node runner the caller supplied (#3022 CR)', () => { + const targetDir = '/tmp/codex-test/.codex'; + const expectedRunnerPath = '/usr/local/bin/node'; + const absoluteRunner = `"${expectedRunnerPath}"`; + const block = buildCodexHookBlock(targetDir, { absoluteRunner }); + const parsed = parseCodexHookBlock(block); + assert.equal(parsed.ok, true, `parse failed: ${block}`); + assert.equal(parsed.hasMarker, true, '# GSD Hooks marker present'); + assert.equal(parsed.hasEvent, true, '[[hooks.SessionStart]] AoT entry present'); + assert.equal(parsed.hasHandler, true, '[[hooks.SessionStart.hooks]] handler entry present'); + assert.equal(parsed.type, 'command', 'handler is type=command'); + // Strict: parsed runner must match the supplied absolute path EXACTLY + // (after stripping toml/JSON escape layers). A loose substring like + // '/node' would let an unrelated absolute token containing '/node' + // pass — e.g. '/Users/x/notnode/foo'. + assert.equal(unescapeRunner(parsed.runner), expectedRunnerPath, + `parsed runner must equal supplied absolute path: got ${parsed.runner}, want ${expectedRunnerPath}`); + // On Windows, path.resolve prepends the current drive letter ("D:") to + // the POSIX-shaped fixture path. Accept either form. + const expectedHookSuffix = '/tmp/codex-test/.codex/hooks/gsd-check-update.js'; + assert.ok( + parsed.hookPath === expectedHookSuffix || + parsed.hookPath.replace(/^[A-Za-z]:/, '') === expectedHookSuffix, + `hook path equality, got: ${parsed.hookPath}, want suffix: ${expectedHookSuffix}`, + ); + }); + + test('returns null when absoluteRunner is null (caller skips registration)', () => { + const block = buildCodexHookBlock('/tmp/x/.codex', { absoluteRunner: null }); + assert.equal(block, null, + 'must return null on missing runner so caller can warn-and-skip instead of writing a broken hook'); + }); + + test('integrates with resolveNodeRunner() in the live process — runner equals resolved node runner (#3022 CR)', () => { + const runner = resolveNodeRunner(); + assert.ok(runner, 'resolveNodeRunner returns a usable value in this test env'); + const block = buildCodexHookBlock('/tmp/x/.codex', { absoluteRunner: runner }); + const parsed = parseCodexHookBlock(block); + assert.equal(parsed.ok, true); + // Strict canonical-runner equality: the parsed runner (after stripping + // toml + JSON escape layers) must be exactly the normalized runner that + // resolveNodeRunner selected. Homebrew Cellar execPath values intentionally + // normalize to the stable Homebrew symlink (#3181). + const expected = JSON.parse(runner); + assert.equal(unescapeRunner(parsed.runner), expected, + `parsed runner must equal resolveNodeRunner(), got: ${parsed.runner}, want: ${expected}`); + }); +}); + +describe('Bug #3017: rewriteLegacyCodexHookBlock migrates bare-node on reinstall', () => { + test('exported as a function', () => { + assert.equal(typeof rewriteLegacyCodexHookBlock, 'function'); + }); + + test('rewrites a bare-node managed-hook command to the absolute runner', () => { + const before = [ + '[model]', + 'name = "o3"', + '', + '# GSD Hooks', + '[[hooks.SessionStart]]', + '', + '[[hooks.SessionStart.hooks]]', + 'type = "command"', + 'command = "node /Users/x/.codex/hooks/gsd-check-update.js"', + '', + ].join('\n'); + const expectedRunnerPath = '/usr/local/bin/node'; + const runner = `"${expectedRunnerPath}"`; + const result = rewriteLegacyCodexHookBlock(before, runner); + assert.equal(result.changed, true, 'must report change=true'); + // The migrated command must use the EXACT absolute runner the caller + // supplied (#3022 CR — was previously asserting a loose '/node' + // substring which let unrelated absolute paths pass). + const parsed = parseCodexHookBlock(result.content); + assert.equal(parsed.ok, true); + assert.equal(unescapeRunner(parsed.runner), expectedRunnerPath, + `runner must equal supplied absolute path: ${parsed.runner}`); + assert.equal(parsed.hookPath, '/Users/x/.codex/hooks/gsd-check-update.js'); + // Non-GSD content (the [model] block) must be preserved verbatim. + assert.ok(result.content.includes('[model]')); + assert.ok(result.content.includes('name = "o3"')); + }); + + test('decodes TOML-escaped quoted script paths before projection', () => { + const before = [ + '# GSD Hooks', + '[[hooks.SessionStart]]', + '', + '[[hooks.SessionStart.hooks]]', + 'type = "command"', + 'command = "node \\"C:\\\\Users\\\\x\\\\.codex\\\\hooks\\\\gsd-check-update.js\\""', + '', + ].join('\n'); + const runner = '"/usr/local/bin/node"'; + const result = rewriteLegacyCodexHookBlock(before, runner, { platform: 'win32' }); + assert.equal(result.changed, true); + const parsed = parseCodexHookBlock(result.content); + assert.equal(parsed.ok, true, 'hook block must parse correctly'); + const expected = projectCodexHookTomlCommand({ + absoluteRunner: runner, + scriptPath: 'C:\\Users\\x\\.codex\\hooks\\gsd-check-update.js', + platform: 'win32', + }); + assert.equal(parsed.command, expected, + 'rewritten command must project from decoded Windows path (not TOML-escaped token text)'); + assert.equal(unescapeRunner(parsed.runner), '/usr/local/bin/node', + 'runner must equal supplied absolute path'); + assert.equal(parsed.hookPath, 'C:/Users/x/.codex/hooks/gsd-check-update.js', + 'hook path must equal decoded Windows path after projection normalization'); + }); + + test('does NOT touch a managed-hook entry that already uses an absolute runner', () => { + const already = [ + '# GSD Hooks', + '[[hooks.SessionStart]]', + '', + '[[hooks.SessionStart.hooks]]', + 'type = "command"', + 'command = "\\"/usr/local/bin/node\\" /Users/x/.codex/hooks/gsd-check-update.js"', + '', + ].join('\n'); + const result = rewriteLegacyCodexHookBlock(already, '"/usr/local/bin/node"'); + assert.equal(result.changed, false); + assert.equal(result.content, already); + }); + + test('does NOT touch user-authored bare-node hooks (filename not in managed allowlist)', () => { + const userOwned = [ + '[[hooks.SessionStart]]', + '', + '[[hooks.SessionStart.hooks]]', + 'type = "command"', + 'command = "node /home/me/my-custom-codex-hook.js"', + '', + ].join('\n'); + const result = rewriteLegacyCodexHookBlock(userOwned, '"/usr/local/bin/node"'); + assert.equal(result.changed, false, + 'user-authored hooks must be left alone; only managed gsd-* hooks are migrated'); + assert.equal(result.content, userOwned); + }); + + test('returns content unchanged when absoluteRunner is null', () => { + const before = 'command = "node /path/to/gsd-check-update.js"'; + const result = rewriteLegacyCodexHookBlock(before, null); + assert.equal(result.changed, false); + assert.equal(result.content, before); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3018-codex-discuss-fallback.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3018-codex-discuss-fallback (consolidation epic #1969 B1 #1970)", () => { +/** + * Regression test for bug #3018. + * + * @jon-hendry: running `$gsd-discuss-phase 81` in Codex Default mode (where + * `request_user_input` is rejected) caused the agent to pick "reasonable + * defaults" and proceed straight into writing CONTEXT.md / DISCUSSION-LOG.md + * checkpoints — without ever surfacing the questions to the user. The + * generated Codex skill adapter explicitly told it to do that: + * + * "When `request_user_input` is rejected (Execute mode), present a + * plain-text numbered list and pick a reasonable default." + * + * Discuss-mode is the wrong place for that fallback. The contract should be: + * stop, render the questions as plain text, wait for the user's answer. + * Defaults may only be picked when the user has authorized non-interactive + * mode (--auto / --all) or has explicitly approved them. + * + * Test design (#3027 CR follow-up): instead of grepping the prose with + * regex, parse the fallback section into a typed semantic-flag record and + * assert on those booleans. This adheres to CONTRIBUTING.md "no-source-grep" + * — the test names a behavioral invariant, the parser walks the prose + * once and exposes the invariants as named flags, and the prose can be + * reworded freely as long as the flags stay true. + */ + +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); + +const INSTALL = require(path.join(__dirname, '..', 'bin', 'install.js')); +const { getCodexSkillAdapterHeader } = INSTALL; +const { tokenizeHeadings } = require('../gsd-core/bin/lib/markdown-sectionizer.cjs'); + +/** + * Extract the "Execute mode fallback" section text from the adapter header. + * Returns null if the section is missing. Section runs from the + * "Execute mode fallback:" label up to the next heading or tag. + */ +function extractExecuteModeFallback(header) { + const label = 'Execute mode fallback:'; + const labelIdx = header.indexOf(label); + if (labelIdx === -1) return null; + const bodyStart = header.indexOf('\n', labelIdx + label.length); + if (bodyStart === -1) return null; + + // End at whichever comes first: the next "## " heading (via the canonical + // heading tokenizer, not an ad-hoc regex) or the closing adapter tag. + const headings = tokenizeHeadings(header).filter((h) => h.level === 2 && h.offset > bodyStart); + const nextHeadingOffset = headings.length > 0 ? headings[0].offset - 1 : Infinity; // -1 for the leading \n + const closeTagIdx = header.indexOf('', bodyStart); + const closeTagOffset = closeTagIdx === -1 ? Infinity : closeTagIdx - 1; // -1 for the leading \n + const bodyEnd = Math.min(nextHeadingOffset, closeTagOffset); + if (bodyEnd === Infinity) return null; + + return header.slice(bodyStart + 1, bodyEnd).trim(); +} + +/** + * Parse the Execute-mode-fallback section into a typed semantic-flag + * record. Each flag answers a single behavioral question that the #3018 + * fix is contractually required to encode in the prose. Tests assert on + * the booleans, not the wording — so the prose can evolve without test + * churn as long as the semantics stay correct. + * + * The flags are derived from a single pass over the section text: each + * one looks for any of a small set of synonym phrases that a correct + * implementation would use. The negative anti-pattern flag + * (`silentlyPicksDefaults`) is the regression guard — the prose under + * #3018 told the agent to "pick a reasonable default" autonomously, + * which is exactly what this fix removes. + */ +function parseExecuteModeFallback(section) { + if (!section || typeof section !== 'string') { + return { + ok: false, + sectionLength: 0, + instructsStop: false, + presentsPlainTextQuestions: false, + namesPermissionPath: false, + forbidsWritingArtifactsBeforeAnswer: false, + silentlyPicksDefaults: false, + }; + } + const lower = section.toLowerCase(); + // (a) STOP/WAIT directive — the agent must halt instead of proceeding. + const instructsStop = /\b(stop|halt|wait)\b/.test(lower); + // (b) Plain-text fallback presentation — the agent must surface the + // questions in some inspectable form (numbered list / plain text). + const presentsPlainTextQuestions = /plain.?text|numbered list/.test(lower); + // (c) Permission path that DOES allow defaults — must name at least + // one (--auto / --all / explicit user approval / autonomous workflow). + const namesPermissionPath = + /--auto|--all/.test(section) || + /explicit(ly)? (approv|authoriz|consent)/i.test(section) || + /user (has )?approv|user (has )?authoriz|user (has )?consent/i.test(section) || + /autonomous (lifecycle|workflow|paths?)/i.test(section); + // (d) Artifact-write ban — the agent must not produce workflow files + // (CONTEXT.md, DISCUSSION-LOG.md, PLAN.md, checkpoints) before the + // user answers or one of the permission-path conditions applies. + // Require BOTH a "do not write" intent AND a named artifact class so + // generic "do not write" prose elsewhere can't satisfy the flag. + const forbidsWriteIntent = /do not write|don'?t write|must not write|shall not write/i.test(section); + const namesArtifactClass = /artifact|checkpoint|context\.md|discussion.?log|plan\.md/i.test(section); + const forbidsWritingArtifactsBeforeAnswer = forbidsWriteIntent && namesArtifactClass; + // Anti-pattern guard — the prose that caused #3018. This MUST be false. + const silentlyPicksDefaults = /pick (a |the )?(reasonable|sensible|sane) default/i.test(section); + return { + ok: true, + sectionLength: section.length, + instructsStop, + presentsPlainTextQuestions, + namesPermissionPath, + forbidsWritingArtifactsBeforeAnswer, + silentlyPicksDefaults, + }; +} + +describe('bug #3018: codex skill adapter encodes the discuss-mode fallback contract', () => { + test('exports the adapter generator', () => { + assert.equal(typeof getCodexSkillAdapterHeader, 'function'); + }); + + test('Execute mode fallback section exists and has content', () => { + const header = getCodexSkillAdapterHeader('gsd-discuss-phase'); + const section = extractExecuteModeFallback(header); + const parsed = parseExecuteModeFallback(section); + assert.equal(parsed.ok, true, `section must parse, got header:\n${header}`); + assert.ok(parsed.sectionLength > 0, 'section must be non-empty'); + }); + + test('fallback instructs STOP/WAIT (not silent continuation)', () => { + const section = extractExecuteModeFallback(getCodexSkillAdapterHeader('gsd-discuss-phase')); + const parsed = parseExecuteModeFallback(section); + assert.equal(parsed.instructsStop, true, + `must instruct stop/halt/wait — section was:\n${section}`); + }); + + test('fallback prescribes plain-text question presentation', () => { + const section = extractExecuteModeFallback(getCodexSkillAdapterHeader('gsd-discuss-phase')); + const parsed = parseExecuteModeFallback(section); + assert.equal(parsed.presentsPlainTextQuestions, true, + `must mention plain-text / numbered-list presentation — section was:\n${section}`); + }); + + test('fallback names a permission path under which defaults ARE allowed (--auto / --all / explicit approval / autonomous)', () => { + const section = extractExecuteModeFallback(getCodexSkillAdapterHeader('gsd-discuss-phase')); + const parsed = parseExecuteModeFallback(section); + assert.equal(parsed.namesPermissionPath, true, + `must name at least one permission path — section was:\n${section}`); + }); + + test('fallback forbids writing workflow artifacts before user answers', () => { + const section = extractExecuteModeFallback(getCodexSkillAdapterHeader('gsd-discuss-phase')); + const parsed = parseExecuteModeFallback(section); + assert.equal(parsed.forbidsWritingArtifactsBeforeAnswer, true, + `must encode write-ban + named artifact class — section was:\n${section}`); + }); + + test('fallback does NOT contain the #3018 anti-pattern ("pick a reasonable default")', () => { + const section = extractExecuteModeFallback(getCodexSkillAdapterHeader('gsd-discuss-phase')); + const parsed = parseExecuteModeFallback(section); + assert.equal(parsed.silentlyPicksDefaults, false, + `regression — fallback must NOT instruct the agent to pick defaults autonomously, section was:\n${section}`); + }); + + test('all four positive flags + the negative anti-pattern flag — typed-record snapshot', () => { + // Single assertion that the whole semantic record matches the contract. + // If any flag flips, the test fails with a structured diff naming the + // exact invariant that broke. + const section = extractExecuteModeFallback(getCodexSkillAdapterHeader('gsd-discuss-phase')); + const parsed = parseExecuteModeFallback(section); + const semanticContract = { + ok: parsed.ok, + instructsStop: parsed.instructsStop, + presentsPlainTextQuestions: parsed.presentsPlainTextQuestions, + namesPermissionPath: parsed.namesPermissionPath, + forbidsWritingArtifactsBeforeAnswer: parsed.forbidsWritingArtifactsBeforeAnswer, + silentlyPicksDefaults: parsed.silentlyPicksDefaults, + }; + assert.deepStrictEqual(semanticContract, { + ok: true, + instructsStop: true, + presentsPlainTextQuestions: true, + namesPermissionPath: true, + forbidsWritingArtifactsBeforeAnswer: true, + silentlyPicksDefaults: false, + }, `discuss-mode fallback contract violated — section was:\n${section}`); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3245-codex-toml-floats.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3245-codex-toml-floats (consolidation epic #1969 B1 #1970)", () => { +/** + * Regression: issue #3245 — Codex install rejects valid TOML floats. + * + * Two defects, two fixes: + * + * Defect 1 — parseTomlValue rejects TOML floats (e.g. tool_timeout_sec = 20.0). + * Codex CLI's serde schema requires f64 for tool_timeout_sec / startup_timeout_sec + * (integers fail with "invalid type: integer"). GSD's strict-integer-only parser + * was the inverse of what Codex requires — any float triggers the rejection branch. + * Fix: extend parseTomlValue to accept TOML 1.0 float literals and return them as + * JS Number. The merged config.toml preserves the float form verbatim so + * round-trip writes don't coerce 20.0 → 20. + * + * Defect 2 — Partial rollback leaves install in hybrid state. + * restoreCodexSnapshot only knew about config.toml, but skills/, agents/, and VERSION + * are written earlier in the install sequence. A post-install validation failure + * aborts with new agent text on disk, config.toml reverted, and .tmp files + * potentially orphaned. + * Fix: capture pre-install state of skills/, agents/, and VERSION before any + * Codex-specific mutation, and extend the rollback to cover all of them. + */ + +// GSD_TEST_MODE must be set before require('../bin/install.js') so the module +// skips the main CLI entry point and exports its internals. +const previousGsdTestMode = process.env.GSD_TEST_MODE; +process.env.GSD_TEST_MODE = '1'; + +const { test, describe, before, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { runNode } = require('./helpers/process-seam.cjs'); +const { throwIfFailed } = require('./helpers/git-fixture.cjs'); +const { cleanup } = require('./helpers.cjs'); + +const { parseTomlToObject, validateCodexConfigSchema, install } = require('../bin/install.js'); +const installModule = require('../bin/install.js'); + +if (previousGsdTestMode === undefined) { + delete process.env.GSD_TEST_MODE; +} else { + process.env.GSD_TEST_MODE = previousGsdTestMode; +} + +// Ensure hooks/dist/ is populated — mirrors the shared hooks/dist bootstrap pattern at the top of this file. +const HOOKS_DIST = path.join(__dirname, '..', 'hooks', 'dist'); +const BUILD_HOOKS_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); +// scripts/build-hooks.js copies pre-built hook files into hooks/dist and +// syntax-checks them with vm — it does not compile/bundle anything. See +// tests/helpers/timeouts.cjs for the class-norm justification. +const { BUILD_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); +before(() => { + if (!fs.existsSync(HOOKS_DIST) || fs.readdirSync(HOOKS_DIST).length === 0) { + throwIfFailed( + runNode([BUILD_HOOKS_SCRIPT], { timeoutMs: BUILD_TIMEOUT_MS }), + `node ${BUILD_HOOKS_SCRIPT}`, + ); + } +}); + +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'); + } finally { + process.chdir(previousCwd); + if (previousCodexHome === undefined) { + delete process.env.CODEX_HOME; + } 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; + } +} + +function writeCodexConfig(codexHome, content) { + fs.mkdirSync(codexHome, { recursive: true }); + fs.writeFileSync(path.join(codexHome, 'config.toml'), content, 'utf8'); +} + +// --------------------------------------------------------------------------- +// Defect 1 — parseTomlValue must accept TOML floats +// --------------------------------------------------------------------------- + +describe('#3245 — parseTomlToObject accepts TOML floats', () => { + test('parses bare decimal float (20.0)', () => { + const content = [ + 'tool_timeout_sec = 20.0', + '', + ].join('\n'); + const parsed = parseTomlToObject(content); + assert.strictEqual(typeof parsed.tool_timeout_sec, 'number', + 'tool_timeout_sec should be a JS number'); + assert.strictEqual(parsed.tool_timeout_sec, 20.0, + 'value must equal 20.0'); + }); + + test('parses startup_timeout_sec = 60.0', () => { + const content = [ + 'startup_timeout_sec = 60.0', + '', + ].join('\n'); + const parsed = parseTomlToObject(content); + assert.strictEqual(parsed.startup_timeout_sec, 60.0); + }); + + test('parses positive exponent notation (1e10)', () => { + const content = [ + 'x = 1e10', + '', + ].join('\n'); + const parsed = parseTomlToObject(content); + assert.strictEqual(parsed.x, 1e10); + }); + + test('parses negative exponent (1.5e-3)', () => { + const content = [ + 'x = 1.5e-3', + '', + ].join('\n'); + const parsed = parseTomlToObject(content); + assert.ok(Math.abs(parsed.x - 1.5e-3) < 1e-15, 'must be approximately 1.5e-3'); + }); + + test('parses signed positive float (+1.0)', () => { + const content = [ + 'x = +1.0', + '', + ].join('\n'); + const parsed = parseTomlToObject(content); + assert.strictEqual(parsed.x, 1.0); + }); + + test('parses signed negative float (-0.5)', () => { + const content = [ + 'x = -0.5', + '', + ].join('\n'); + const parsed = parseTomlToObject(content); + assert.strictEqual(parsed.x, -0.5); + }); + + test('parses float with underscore separators (1_000.0)', () => { + const content = [ + 'x = 1_000.0', + '', + ].join('\n'); + const parsed = parseTomlToObject(content); + assert.strictEqual(parsed.x, 1000.0); + }); + + test('integer (no decimal) still parses as integer', () => { + const content = [ + 'x = 42', + '', + ].join('\n'); + const parsed = parseTomlToObject(content); + assert.strictEqual(parsed.x, 42); + }); + + test('still rejects bare date (1979-05-27)', () => { + const content = [ + 'x = 1979-05-27', + '', + ].join('\n'); + assert.throws( + () => parseTomlToObject(content), + /unsupported TOML value/, + 'date literals must remain unsupported' + ); + }); + + test('still rejects bare time (07:32:00)', () => { + const content = [ + 'x = 07:32:00', + '', + ].join('\n'); + // With leading-zero rejection (CR4 fix) the parser stops at `0`, and + // `7:32:00` is "trailing bytes". Either error form is acceptable — the + // key invariant is that time literals are never silently accepted. + assert.throws( + () => parseTomlToObject(content), + /unsupported TOML value|trailing bytes/, + 'time literals must remain unsupported' + ); + }); + + test('still rejects hex literal (0x1A)', () => { + const content = [ + 'x = 0x1A', + '', + ].join('\n'); + // 0 is parsed, then 'x1A' is trailing garbage — rejected with "trailing bytes" + // or "unsupported value" depending on where the parser catches it. + assert.throws( + () => parseTomlToObject(content), + /trailing bytes|unsupported (TOML value|value)/, + 'hex literals must remain unsupported' + ); + }); + + test('validateCodexConfigSchema passes a config with tool_timeout_sec = 20.0', () => { + const content = [ + '[model]', + 'name = "o3"', + '', + 'tool_timeout_sec = 20.0', + 'startup_timeout_sec = 60.0', + '', + ].join('\n'); + const result = validateCodexConfigSchema(content); + assert.strictEqual(result.ok, true, + 'schema validation must pass for a config containing TOML floats: ' + result.reason); + }); +}); + +// --------------------------------------------------------------------------- +// Defect 1 — full install must succeed and preserve float verbatim +// --------------------------------------------------------------------------- + +// concurrency: false — drives the live install pipeline (shared CODEX_HOME env, +// process.chdir). Serialise to prevent stray mutations across parallel siblings. +describe('#3245 — install succeeds with TOML float in pre-existing config', { concurrency: false }, () => { + let tmpDir; + let codexHome; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3245-float-')); + codexHome = path.join(tmpDir, 'codex-home'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('install completes when config.toml contains tool_timeout_sec = 20.0', () => { + // Floats at the root level (before any table header) — this is where Codex + // CLI reads tool_timeout_sec / startup_timeout_sec according to its serde schema. + const preInstall = [ + 'tool_timeout_sec = 20.0', + 'startup_timeout_sec = 60.0', + '', + '[model]', + 'name = "o3"', + '', + ].join('\n'); + writeCodexConfig(codexHome, preInstall); + + // Must not throw — pre-#3245 this threw "unsupported TOML value … floats … not supported". + assert.doesNotThrow( + () => runCodexInstall(codexHome), + 'install must not throw when config.toml contains TOML floats' + ); + + // The merged config.toml must still contain the float values at root scope. + const after = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8'); + const parsed = parseTomlToObject(after); + assert.strictEqual(parsed.tool_timeout_sec, 20.0, + 'tool_timeout_sec must be preserved as a number after install'); + assert.strictEqual(parsed.startup_timeout_sec, 60.0, + 'startup_timeout_sec must be preserved as a number after install'); + }); + + test('post-install config round-trips tool_timeout_sec as numeric 20', () => { + const preInstall = [ + 'tool_timeout_sec = 20.0', + '', + ].join('\n'); + writeCodexConfig(codexHome, preInstall); + + runCodexInstall(codexHome); + + const after = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8'); + // The value must survive round-trip as a float-compatible representation. + // Parse structurally — don't grep for the literal string "20.0". + const parsed = parseTomlToObject(after); + assert.strictEqual(parsed.tool_timeout_sec, 20, + 'tool_timeout_sec must round-trip as numeric 20 (=== 20.0 in JS)'); + }); +}); + +// --------------------------------------------------------------------------- +// CR round-4 finding — TOML 1.0 disallows leading zeros in integer part +// --------------------------------------------------------------------------- +// +// TOML 1.0 §2: integer literals follow decimal-integer rules, which disallow +// leading zeros except the value `0` itself. `01`, `01.5`, `00e2`, `+01.0` +// are therefore invalid. The `parseTomlValue` integer-part regex is tightened +// from `\d(?:_?\d)*` to `(0|[1-9](?:_?\d)*)`. + +describe('#3245 CR4 — parseTomlValue rejects leading zeros in float integer part', () => { + function parseValue(raw) { + // Wrap in a minimal TOML assignment so parseTomlToObject drives the test. + return parseTomlToObject(`x = ${raw}`).x; + } + + function assertRejects(raw, label) { + let threw = false; + try { parseValue(raw); } catch (_) { threw = true; } + assert.strictEqual(threw, true, `expected rejection for ${label}: ${raw}`); + } + + function assertAccepts(raw, expected, label) { + let val; + let threw = false; + try { val = parseValue(raw); } catch (e) { threw = true; } + assert.strictEqual(threw, false, `expected acceptance for ${label}: ${raw}`); + if (expected !== undefined) { + assert.ok(Math.abs(val - expected) < 1e-12, `${label}: expected ${expected}, got ${val}`); + } + } + + // --- rejection cases: leading zeros in the integer part --- + + test('rejects 01 (leading zero on bare integer)', () => assertRejects('01', '01')); + test('rejects 00 (double-zero bare integer)', () => assertRejects('00', '00')); + test('rejects 01.5 (leading zero before decimal point)', () => assertRejects('01.5', '01.5')); + test('rejects 00.5 (double-zero before decimal)', () => assertRejects('00.5', '00.5')); + test('rejects +01 (leading zero with sign)', () => assertRejects('+01', '+01')); + test('rejects -01 (negative leading zero)', () => assertRejects('-01', '-01')); + test('rejects 00e2 (leading zero with exponent)', () => assertRejects('00e2', '00e2')); + test('rejects +01.0 (leading zero in positive float)', () => assertRejects('+01.0', '+01.0')); + test('rejects -01.0 (leading zero in negative float)', () => assertRejects('-01.0', '-01.0')); + test('rejects 01.5e10 (leading zero, decimal, and exponent)', () => assertRejects('01.5e10', '01.5e10')); + + // --- acceptance cases: valid TOML 1.0 numeric forms --- + + test('accepts 0 (single zero)', () => assertAccepts('0', 0, 'single zero')); + test('accepts 0.5 (zero before decimal)', () => assertAccepts('0.5', 0.5, 'zero.decimal')); + test('accepts 0.0 (zero.zero)', () => assertAccepts('0.0', 0.0, 'zero.zero')); + test('accepts 0e1 (zero with exponent)', () => assertAccepts('0e1', 0, '0e1')); + test('accepts +0.5 (positive zero-decimal)', () => assertAccepts('+0.5', 0.5, '+0.5')); + test('accepts -0.5 (negative zero-decimal)', () => assertAccepts('-0.5', -0.5, '-0.5')); + test('accepts 1 (single non-zero digit)', () => assertAccepts('1', 1, '1')); + test('accepts 12 (two digits)', () => assertAccepts('12', 12, '12')); + test('accepts 1.5 (simple float)', () => assertAccepts('1.5', 1.5, '1.5')); + test('accepts 1_000 (underscored integer)', () => assertAccepts('1_000', 1000, '1_000')); + test('accepts 1_000.5 (underscored float)', () => assertAccepts('1_000.5', 1000.5, '1_000.5')); + test('accepts +1.5 (positive float)', () => assertAccepts('+1.5', 1.5, '+1.5')); + test('accepts -2.0 (negative float)', () => assertAccepts('-2.0', -2.0, '-2.0')); + test('accepts 1.5e-3 (float with negative exponent)', () => assertAccepts('1.5e-3', 1.5e-3, '1.5e-3')); + test('accepts 1.05e10 (fractional part may start with zero)', () => assertAccepts('1.05e10', 1.05e10, '1.05e10')); +}); + +// --------------------------------------------------------------------------- +// Defect 2 — idempotent rollback covers skills, agents, VERSION +// --------------------------------------------------------------------------- + +// concurrency: false — patches module.exports.__codexSchemaValidator and drives +// the install pipeline. Serialise to prevent cross-test pollution. +describe('#3245 — idempotent rollback reverts skills/, agents/, and VERSION', { concurrency: false }, () => { + let tmpDir; + let codexHome; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3245-rollback-')); + codexHome = path.join(tmpDir, 'codex-home'); + }); + + afterEach(() => { + delete installModule.__codexSchemaValidator; + cleanup(tmpDir); + }); + + test('validation failure rolls back skills/, agents/, and VERSION to pre-install state', () => { + // Start from a clean codexHome with no pre-existing GSD content — the dirs + // do not exist yet. After a failed install they must be absent (or contain + // only what was there before, i.e. nothing). + fs.mkdirSync(codexHome, { recursive: true }); + + // Force schema validation to fail so we can observe the rollback without + // needing a genuinely broken config. + installModule.__codexSchemaValidator = () => ({ + ok: false, + reason: 'simulated failure for #3245 rollback test', + }); + + let threw = false; + try { + runCodexInstall(codexHome); + } catch (_) { + threw = true; + } + assert.strictEqual(threw, true, 'install must throw when validation fails'); + + // skills/ — GSD writes gsd-* subdirs here. All must be absent after rollback. + const skillsDir = codexSkillsRoot(codexHome); + if (fs.existsSync(skillsDir)) { + const gsdSkills = fs.readdirSync(skillsDir, { withFileTypes: true }) + .filter(e => e.isDirectory() && e.name.startsWith('gsd-')); + assert.strictEqual( + gsdSkills.length, + 0, + 'rollback must remove all gsd-* skill directories: ' + gsdSkills.map(e => e.name).join(', ') + ); + } + + // agents/ — GSD writes gsd-*.md and gsd-*.toml here. All must be absent. + // Not the shared listAgentFiles() helper: reads the INSTALLED Codex dest + // dir and is .toml-inclusive, so its semantics differ from the source roster. + const agentsDir = path.join(codexHome, 'agents'); + if (fs.existsSync(agentsDir)) { + const gsdAgents = fs.readdirSync(agentsDir) + .filter(f => f.startsWith('gsd-') && (f.endsWith('.md') || f.endsWith('.toml'))); + assert.strictEqual( + gsdAgents.length, + 0, + 'rollback must remove all gsd-* agent files: ' + gsdAgents.join(', ') + ); + } + + // VERSION — GSD writes gsd-core/VERSION. Must be absent (wasn't there before). + const versionPath = path.join(codexHome, 'gsd-core', 'VERSION'); + assert.strictEqual( + fs.existsSync(versionPath), + false, + 'rollback must remove the VERSION file written during install' + ); + }); + + test('rollback is safe when fired before any snapshots were captured (very early failure)', () => { + // If the validator is injected before ANY install writes happen, the rollback + // must not throw — it should be idempotent when nothing was written yet. + fs.mkdirSync(codexHome, { recursive: true }); + + installModule.__codexSchemaValidator = () => ({ + ok: false, + reason: 'very early simulated failure', + }); + + // The install must throw (validation failure), but the rollback that runs + // internally must not throw — it must be idempotent when nothing was written. + let threw = false; + try { + runCodexInstall(codexHome); + } catch (_) { + threw = true; + } + 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 = codexSkillsRoot(codexHome); + const remainingGsdSkills = fs.existsSync(skillsDir) + ? fs.readdirSync(skillsDir, { withFileTypes: true }) + .filter((e) => e.isDirectory() && e.name.startsWith('gsd-')) + .map((e) => e.name) + : []; + assert.deepStrictEqual( + remainingGsdSkills, + [], + 'rollback must remove all gsd-* skill dirs even when fired after minimal writes' + ); + }); + + 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 = 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'); + + installModule.__codexSchemaValidator = () => ({ + ok: false, + reason: 'simulated failure — user skill must survive', + }); + + let threw = false; + try { runCodexInstall(codexHome); } catch (_) { threw = true; } + assert.strictEqual(threw, true, 'expected runCodexInstall to throw under simulated validation failure (user-skill-survives scenario)'); + + assert.strictEqual( + fs.existsSync(path.join(userSkill, 'SKILL.md')), + true, + 'pre-existing non-gsd-* skill must survive rollback' + ); + }); + + test('rollback removes orphaned atomic-write temp files', () => { + // Any .tmp-- files created during aborted atomic writes + // must be cleaned up by the rollback so targetDir is not left with stray + // temp files consuming disk space. + fs.mkdirSync(codexHome, { recursive: true }); + + installModule.__codexSchemaValidator = () => ({ + ok: false, + reason: 'simulated failure for temp-file cleanup test', + }); + + let threw = false; + try { runCodexInstall(codexHome); } catch (_) { threw = true; } + assert.strictEqual(threw, true, 'expected runCodexInstall to throw under simulated validation failure (temp-file cleanup scenario)'); + + // Scan for any *.tmp-* files left in codexHome after rollback. + const tmpPattern = /\.tmp-\d+-\d+$/; + function findTmpFiles(dir) { + if (!fs.existsSync(dir)) return []; + const results = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + results.push(...findTmpFiles(full)); + } else if (tmpPattern.test(entry.name)) { + results.push(full); + } + } + return results; + } + const stray = findTmpFiles(codexHome); + assert.strictEqual( + stray.length, + 0, + 'rollback must clean up orphaned atomic-write temp files: ' + stray.join(', ') + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3285-codex-hooks-state-allowed.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3285-codex-hooks-state-allowed (consolidation epic #1969 B1 #1970)", () => { +/** + * Regression: issue #3285 — Codex install fails when config.toml contains + * hooks.state entries. + * + * Root cause: validateCodexConfigSchema walks every `hooks.*` table section + * and asserts array-of-tables (AoT) shape, without distinguishing the + * `hooks.state.*` namespace (Codex-managed per-hook trust persistence, a + * regular table) from `hooks.` (event handlers like SessionStart, + * which DO require AoT shape via [[hooks.SessionStart]]). + * + * Fix: add a carve-out so that any table whose path starts with `hooks.state` + * is validated as a regular table (not AoT). All `hooks.` paths still + * require AoT. + */ + +// GSD_TEST_MODE must be set before require('../bin/install.js') so the module +// skips the main CLI entry point and exports its internals. +const previousGsdTestMode = process.env.GSD_TEST_MODE; +process.env.GSD_TEST_MODE = '1'; + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { runNode } = require('./helpers/process-seam.cjs'); +const { throwIfFailed } = require('./helpers/git-fixture.cjs'); + +const { validateCodexConfigSchema, install } = require('../bin/install.js'); +const { cleanup } = require('./helpers.cjs'); + +if (previousGsdTestMode === undefined) { + delete process.env.GSD_TEST_MODE; +} else { + process.env.GSD_TEST_MODE = previousGsdTestMode; +} + +// Ensure hooks/dist/ is populated — mirrors the shared hooks/dist bootstrap pattern at the top of this file. +const { before, beforeEach, afterEach } = require('node:test'); +const HOOKS_DIST = path.join(__dirname, '..', 'hooks', 'dist'); +const BUILD_HOOKS_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); +// scripts/build-hooks.js copies pre-built hook files into hooks/dist and +// syntax-checks them with vm — it does not compile/bundle anything. See +// tests/helpers/timeouts.cjs for the class-norm justification. +const { BUILD_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); +before(() => { + if (!fs.existsSync(HOOKS_DIST) || fs.readdirSync(HOOKS_DIST).length === 0) { + throwIfFailed( + runNode([BUILD_HOOKS_SCRIPT], { timeoutMs: BUILD_TIMEOUT_MS }), + `node ${BUILD_HOOKS_SCRIPT}`, + ); + } +}); + +// --------------------------------------------------------------------------- +// Validator unit tests (no install, just validateCodexConfigSchema) +// --------------------------------------------------------------------------- + +describe('#3285 — validateCodexConfigSchema: hooks.state is a regular table (not AoT)', () => { + test('bare [hooks.state] table header passes validation', () => { + const content = [ + '[hooks.state]', + '', + ].join('\n'); + const result = validateCodexConfigSchema(content); + assert.strictEqual(result.ok, true, + 'bare [hooks.state] must be allowed (regular-table namespace): ' + result.reason); + }); + + test('bare [hooks.state.] table header passes validation', () => { + // Mirrors the exact shape Codex CLI 0.130.0+ writes for per-hook trust entries. + // The key contains slashes and colons — must be quoted in TOML. + const content = [ + '[hooks.state]', + '', + "[hooks.state.'/home/user/.codex/hooks.json:pre_tool_use:0:0']", + 'enabled = true', + 'trusted_hash = "sha256:abc123"', + '', + ].join('\n'); + const result = validateCodexConfigSchema(content); + assert.strictEqual(result.ok, true, + 'bare [hooks.state.] with trust fields must be allowed: ' + result.reason); + }); + + test('hooks.state alongside [[hooks.SessionStart]] AoT both pass', () => { + // The real-world fixture: user has both Codex trust state AND GSD-managed + // event hooks in the same config.toml. + const content = [ + '[hooks.state]', + '', + "[hooks.state.'/home/user/.codex/hooks.json:pre_tool_use:0:0']", + 'enabled = true', + 'trusted_hash = "sha256:abc123"', + '', + '[[hooks.SessionStart]]', + '', + '[[hooks.SessionStart.hooks]]', + 'type = "command"', + 'command = "/usr/local/bin/gsd-check-update"', + '', + ].join('\n'); + const result = validateCodexConfigSchema(content); + assert.strictEqual(result.ok, true, + 'mixed hooks.state (regular table) + [[hooks.SessionStart]] (AoT) must pass: ' + result.reason); + }); + + test('[[hooks.SessionStart]] AoT still requires array-of-tables shape', () => { + // Regression guard: the fix must NOT relax AoT requirements for event hooks. + // [hooks.SessionStart] (single-bracket) must still fail. + const content = [ + '[hooks.SessionStart]', + 'type = "command"', + 'command = "/some/command"', + '', + ].join('\n'); + const result = validateCodexConfigSchema(content); + assert.strictEqual(result.ok, false, + '[hooks.SessionStart] bare table (not AoT) must still be rejected'); + assert.ok( + result.reason.includes('hooks.SessionStart'), + 'rejection reason must mention hooks.SessionStart, got: ' + result.reason + ); + }); + + test('hooks.state object in parsed structure does not trigger non-array rejection', () => { + // The parsed-object check loops over Object.entries(parsed.hooks) and + // asserts !Array.isArray(value) → error. hooks.state is an object, not + // an array. The fix must skip hooks.state in that loop too. + const content = [ + '[hooks.state]', + '', + "[hooks.state.'some-key']", + 'enabled = true', + 'trusted_hash = "sha256:deadbeef"', + '', + ].join('\n'); + const result = validateCodexConfigSchema(content); + assert.strictEqual(result.ok, true, + 'parsed hooks.state object must not trigger "hooks.state must be an array" rejection: ' + result.reason); + }); + + test('multiple hooks.state sub-keys all pass validation', () => { + const content = [ + '[hooks.state]', + '', + "[hooks.state.'/project/a/.codex/hooks.json:pre_tool_use:0:0']", + 'enabled = true', + 'trusted_hash = "sha256:aaa"', + '', + "[hooks.state.'/project/b/.codex/hooks.json:pre_tool_use:0:0']", + 'enabled = false', + 'trusted_hash = "sha256:bbb"', + '', + ].join('\n'); + const result = validateCodexConfigSchema(content); + assert.strictEqual(result.ok, true, + 'multiple hooks.state sub-keys must all pass: ' + result.reason); + }); + + test('[[hooks.state]] AoT form is rejected', () => { + // hooks.state must be a regular table — array-of-tables shape is invalid. + const content = [ + '[[hooks.state]]', + 'enabled = true', + '', + ].join('\n'); + const result = validateCodexConfigSchema(content); + assert.strictEqual(result.ok, false, + '[[hooks.state]] (AoT) must be rejected'); + assert.ok( + result.reason.includes('hooks.state'), + 'rejection reason must mention hooks.state, got: ' + result.reason + ); + }); + + test('[[hooks.state.foo]] AoT sub-key form is rejected', () => { + // hooks.state.* sub-keys must be regular tables — AoT sub-key shape is invalid. + const content = [ + '[[hooks.state.foo]]', + 'enabled = true', + '', + ].join('\n'); + const result = validateCodexConfigSchema(content); + assert.strictEqual(result.ok, false, + '[[hooks.state.foo]] (AoT sub-key) must be rejected'); + assert.ok( + result.reason.includes('hooks.state'), + 'rejection reason must mention hooks.state, got: ' + result.reason + ); + }); +}); + +// --------------------------------------------------------------------------- +// Full install integration test +// --------------------------------------------------------------------------- + +describe('#3285 — install succeeds when config.toml contains hooks.state entries', { concurrency: false }, () => { + let tmpDir; + let codexHome; + + function writeCodexConfig(content) { + fs.mkdirSync(codexHome, { recursive: true }); + fs.writeFileSync(path.join(codexHome, 'config.toml'), content, 'utf8'); + } + + 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'); + } finally { + process.chdir(previousCwd); + if (previousCodexHome === undefined) { + delete process.env.CODEX_HOME; + } 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; + } + } + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3285-')); + codexHome = path.join(tmpDir, 'codex-home'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('install does not throw when config.toml contains hooks.state trust entries', () => { + // This is the exact failure scenario reported in #3285. + const preInstall = [ + '[hooks.state]', + '', + "[hooks.state.'/home/user/.codex/hooks.json:pre_tool_use:0:0']", + 'enabled = true', + 'trusted_hash = "sha256:abc123def456"', + '', + ].join('\n'); + writeCodexConfig(preInstall); + + assert.doesNotThrow( + () => runCodexInstall(), + 'install must not throw when config.toml contains hooks.state trust entries' + ); + }); + + test('hooks.state entries are preserved in post-install config.toml', () => { + const preInstall = [ + '[hooks.state]', + '', + "[hooks.state.'/home/user/.codex/hooks.json:pre_tool_use:0:0']", + 'enabled = true', + 'trusted_hash = "sha256:abc123def456"', + '', + ].join('\n'); + writeCodexConfig(preInstall); + + runCodexInstall(); + + const after = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8'); + // Verify structurally: the trust hash key must survive the install. + // Do NOT grep for the literal string — parse the TOML structure. + const { parseTomlToObject } = require('../bin/install.js'); + const parsed = parseTomlToObject(after); + assert.ok( + parsed.hooks && typeof parsed.hooks.state === 'object' && parsed.hooks.state !== null, + 'post-install config.toml must have hooks.state as an object' + ); + // Verify the actual trust entry survives — not just that hooks.state is an object. + const trustKey = "/home/user/.codex/hooks.json:pre_tool_use:0:0"; + assert.ok( + parsed.hooks.state[trustKey] != null, + `post-install must preserve the original trust entry for key: ${trustKey}` + ); + assert.strictEqual( + parsed.hooks.state[trustKey].enabled, + true, + 'preserved trust entry must have enabled = true' + ); + assert.strictEqual( + parsed.hooks.state[trustKey].trusted_hash, + 'sha256:abc123def456', + 'preserved trust entry must have the original trusted_hash' + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3346-codex-aot-toml-key.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3346-codex-aot-toml-key (consolidation epic #1969 B1 #1970)", () => { +/** + * Regression: issue #3346 — Codex install fails on Windows when the legacy + * Codex `[hooks]` config uses a `:::` location tuple + * as the table key (with the actual event name carried in an `event = "..."` + * body field). `migrateCodexHooksMapFormat` re-emitted the location tuple + * verbatim as the leaf TOML key, producing a header like + * + * [[hooks."C:\Users\helen\.codex\config.toml:session_start:0:0"]] + * + * which Codex 0.124.0+ refuses to load (the leaf key segment is supposed to + * be the event name, not a diagnostic location identifier). + * + * Expected behaviour: when the legacy `[hooks.]` body declares an + * `event = "..."` field, the migrator must use that event name as the leaf + * TOML key for the emitted `[[hooks.]]` two-level nested AoT block. + * + * Test discipline: parse the migrated TOML with the project's own + * `parseTomlToObject` and assert on the resulting object shape — never + * grep the raw string. + */ + +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); + +const { + migrateCodexHooksMapFormat, + parseTomlToObject, +} = require('../bin/install.js'); + +describe('#3346 — Codex AoT hooks migration emits event-name leaf key, not location tuple', () => { + test('legacy [hooks.""] with event="..." body migrates to [[hooks.]]', () => { + // Pre-install fixture: a legacy `[hooks.]` block whose key is + // a `:::` location identifier. The actual + // event name lives in the body as `event = "session_start"`. + const legacy = [ + '[hooks."C:\\\\Users\\\\helen\\\\.codex\\\\config.toml:session_start:0:0"]', + 'event = "session_start"', + 'command = "echo hi"', + '', + ].join('\n'); + + const migrated = migrateCodexHooksMapFormat(legacy); + const parsed = parseTomlToObject(migrated); + + // The migrated hooks object must be keyed by the event name, not by the + // location tuple. This is the core assertion of #3346. + assert.ok(parsed.hooks, 'migrated TOML must define a hooks table'); + assert.deepEqual( + Object.keys(parsed.hooks), + ['session_start'], + `migrated hooks must be keyed by event name only; got: ${JSON.stringify(Object.keys(parsed.hooks))}` + ); + + // The handler body must survive the migration and live under the two-level + // nested AoT shape (hooks.[0].hooks[0].command). + const eventEntries = parsed.hooks.session_start; + assert.ok(Array.isArray(eventEntries) && eventEntries.length >= 1, + 'hooks.session_start must be an array of tables'); + const handlers = eventEntries[0].hooks; + assert.ok(Array.isArray(handlers) && handlers.length >= 1, + 'hooks.session_start[0].hooks must be an array of handler tables'); + assert.equal(handlers[0].command, 'echo hi', + 'handler command must be preserved through migration'); + assert.equal(handlers[0].type, 'command', + 'handler type must default to "command" when no explicit type given'); + assert.equal(handlers[0].event, undefined, + 'handler body must not retain legacy `event` field after migration'); + }); + + test('legacy [hooks.""] with explicit type and event survives migration cleanly', () => { + // Same as above but with an explicit `type` field — the migrator must not + // duplicate it when re-emitting the handler. + const legacy = [ + '[hooks."/home/user/.codex/config.toml:tool_call_pre:5:0"]', + 'event = "tool_call_pre"', + 'type = "command"', + 'command = "node /path/to/hook.js"', + '', + ].join('\n'); + + const migrated = migrateCodexHooksMapFormat(legacy); + const parsed = parseTomlToObject(migrated); + + assert.deepEqual( + Object.keys(parsed.hooks), + ['tool_call_pre'], + 'leaf key must be the event name from the `event = "..."` body field' + ); + const handler = parsed.hooks.tool_call_pre[0].hooks[0]; + assert.equal(handler.command, 'node /path/to/hook.js'); + assert.equal(handler.type, 'command'); + assert.equal(handler.event, undefined, + 'handler body must not retain legacy `event` field after migration'); + }); + + test('legacy [hooks.] without location-tuple key continues to work unchanged', () => { + // Regression guard: the fix must not break the canonical legacy-map case + // ([hooks.] with handler-fields-only body, no `event` key). + const legacy = [ + '[hooks.session_start]', + 'command = "echo hi"', + '', + ].join('\n'); + + const migrated = migrateCodexHooksMapFormat(legacy); + const parsed = parseTomlToObject(migrated); + + assert.deepEqual( + Object.keys(parsed.hooks), + ['session_start'], + 'bare-event legacy shape must continue to migrate to event-named leaf key' + ); + assert.equal(parsed.hooks.session_start[0].hooks[0].command, 'echo hi'); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3360-codex-execute-phase-worktrees.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3360-codex-execute-phase-worktrees (consolidation epic #1969 B1 #1970)", () => { +/** + * Regression test for bug #3360. + * + * Codex does not have a direct equivalent of Claude Code's + * `Agent(... isolation="worktree")`. The execute-phase workflow must fail + * closed for Codex + workflow.use_worktrees=true instead of spawning + * workspace-write executors in the main checkout. + */ + +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const ROOT = path.join(__dirname, '..'); +const EXECUTE_PHASE = path.join(ROOT, 'gsd-core', 'workflows', 'execute-phase.md'); +const { getCodexSkillAdapterHeader } = require('../bin/install.js'); + +function parseWorkflowSteps(content) { + return [...content.matchAll(/]*>([\s\S]*?)<\/step>/g)] + .map((match) => { + const body = match[2]; + return { + name: match[1], + // After #3797 architectural fix, callsites use gsd_run + readsRuntimeConfig: body.includes('RUNTIME=$(gsd_run query config-get runtime --default claude'), + // #1521 generalized the guard from Codex-specific to all non-Claude + // runtimes; #2584 Phase 3 (#2627) generalized it again — off runtime + // identity entirely and onto the negotiated `dispatch.isolation` + // capability. The step now resolves ISOLATION (delegating the block to + // the isolation-dispatch fragment) and fails closed when a host + // declares no primitive, which is what #3360 actually protects. + resolvesIsolationCapability: body.includes('Resolve ISOLATION'), + // Worktree dispatch guidance is no longer a hardcoded Claude flag — + // step 3 emits the host's DECLARED harness flag. + worktreeDispatchGuidance: body.includes('{harnessFlag}') + || body.includes('executor-isolation-dispatch.md'), + }; + }); +} + +function executePhaseWorktreeContract(content) { + const steps = parseWorkflowSteps(content); + const initializeIndex = steps.findIndex((step) => step.name === 'initialize'); + const firstWorktreeDispatchIndex = steps.findIndex((step) => step.worktreeDispatchGuidance); + assert.notEqual(initializeIndex, -1, 'workflow must have an initialize step'); + assert.notEqual(firstWorktreeDispatchIndex, -1, 'workflow must still document worktree dispatch guidance'); + + const initialize = steps[initializeIndex]; + return { + initializeReadsRuntimeConfig: initialize.readsRuntimeConfig, + initializeResolvesIsolationCapability: initialize.resolvesIsolationCapability, + guardStepPrecedesWorktreeDispatch: initializeIndex <= firstWorktreeDispatchIndex, + }; +} + +describe('#3360 — execute-phase fails closed for unsupported worktree isolation', () => { + // #2584 Phase 3 (#2627) moved this from "Codex is blocked by name" to "a host + // with no declared isolation primitive is blocked". Codex now DECLARES + // orchestrator-worktree and gets a real isolated path, so the guard can no + // longer key on its name — but #3360's actual protection (never run executors + // unisolated against the main checkout) is unchanged and asserted below. + const ISOLATION_FRAGMENT = path.join( + ROOT, 'gsd-core', 'workflows', 'execute-phase', 'steps', 'executor-isolation-dispatch.md', + ); + + test('execute-phase resolves the isolation capability before any worktree dispatch', () => { + const workflow = fs.readFileSync(EXECUTE_PHASE, 'utf8'); + const contract = executePhaseWorktreeContract(workflow); + + assert.deepEqual(contract, { + initializeReadsRuntimeConfig: true, + initializeResolvesIsolationCapability: true, + guardStepPrecedesWorktreeDispatch: true, + }); + }); + + test('a host declaring no isolation primitive still fails closed', () => { + const fragment = fs.readFileSync(ISOLATION_FRAGMENT, 'utf8'); + assert.match(fragment, /ISOLATION="?none"?/, + 'fragment must resolve the none case'); + assert.match(fragment, /FATAL[^\n]*no executor-isolation primitive/, + 'a host with dispatch.isolation=none must fail closed before dispatch (#3360)'); + assert.match(fragment, /use_worktrees=false/, + 'the fail-closed message must tell the user how to proceed'); + }); + + test('the scheduler never gates worktree dispatch on a runtime name', () => { + const workflow = fs.readFileSync(EXECUTE_PHASE, 'utf8'); + const fragment = fs.readFileSync(ISOLATION_FRAGMENT, 'utf8'); + for (const [label, src] of [['execute-phase.md', workflow], ['isolation fragment', fragment]]) { + assert.ok( + !/\[\s*"\$RUNTIME"\s*(?:!=|=)\s*"(?:codex|claude)"\s*\]\s*&&\s*\[\s*"\$USE_WORKTREES"/.test(src), + `${label}: worktree dispatch must branch on dispatch.isolation, not a runtime name (ADR-1239)`, + ); + } + }); + + test('Codex adapter documents the orchestrator-managed worktree mapping', () => { + const header = getCodexSkillAdapterHeader('gsd-execute-phase'); + assert.match(header, /isolation="worktree"/); + assert.match(header, /orchestrator-worktree/i, + 'the adapter header must no longer claim Codex has no worktree mapping — #2584 Phase 3 gave it one'); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3426-codex-windows-hooks.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3426-codex-windows-hooks (consolidation epic #1969 B1 #1970)", () => { +'use strict'; + +/** + * Bug #3426 — Codex on Windows: SessionStart/PostToolUse hooks fail with exit code 1 + * + * After PRs #3396/#3397 fixed bare-bash and quote-escaping issues, a new failure + * mode appeared on v1.42.3+: + * + * Failed with non-blocking status code: + * C:/Program Files/Git/bin/bash.exe: C:/Program Files/Git/bin/bash.exe: cannot execute binary file + * + * Root cause: Codex on Windows runs hook commands from a PowerShell/cmd + * execution environment (see install.js comment at buildHookCommand). The + * command string written to hooks.json was: + * + * "C:/Program Files/nodejs/node.exe" "C:/path/.codex/hooks/gsd-check-update.js" + * + * When Codex's hook runner passes this to its subprocess spawner, the quoted + * path resolves through Git Bash (MSYS), which then tries to POSIX-exec + * node.exe — a Windows PE binary — via the MSYS exec layer. The MSYS exec + * path calls execvp() on the PE binary directly, which fails with ENOEXEC, + * reported as "cannot execute binary file". The "bash.exe: bash.exe:" prefix + * appears because the error propagates through the bash.exe process that Codex + * uses as its hook-dispatch shell. + * + * Fix: on Windows, write a .cmd shim (using the same buildWindowsShimTriple + * IR pattern as gsd-sdk.cmd) and put the .cmd path as the hooks.json command. + * cmd.exe executes .cmd files natively via CreateProcess — no POSIX exec layer, + * no MSYS shebang walk. + * + * Test strategy: + * - Assert on the typed IR returned by buildCodexHookWindowsShimIR — not on + * rendered .cmd text (per CONTRIBUTING.md L558-L565 IR-first discipline). + * - Counter-tests confirm darwin/linux paths are unchanged. + * + * NOTE: Windows wall-clock verification depends on Docker matrix Windows + * runners. Local test exercises the generator IR shape only. + */ + +process.env.GSD_TEST_MODE = '1'; + +const { describe, test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const INSTALL = require('../bin/install.js'); +const HOOKS_SURFACE = require('../gsd-core/bin/lib/runtime-hooks-surface.cjs'); +const PROJECTION = require('../gsd-core/bin/lib/shell-command-projection.cjs'); +const { createTempDir, cleanup } = require('./helpers.cjs'); + +const { + uninstall, +} = INSTALL; + +const { + buildCodexHookWindowsShimIR, + ensureCodexHooksJsonSessionStart, + resolveNodeRunner, +} = HOOKS_SURFACE; + +const { projectManagedHookCommand } = PROJECTION; + +/** + * Extract hook handler objects for `eventName` from a hooks.json object. + * Handles both the legacy top-level shape { SessionStart: [...] } and the + * canonical nested shape { hooks: { SessionStart: [...] } } (bug #1348). + */ +function hookHandlersForEvent(hooksJson, eventName) { + if (!hooksJson || typeof hooksJson !== 'object') return []; + const table = + hooksJson.hooks && typeof hooksJson.hooks === 'object' && !Array.isArray(hooksJson.hooks) + ? hooksJson.hooks + : hooksJson; + if (!Array.isArray(table[eventName])) return []; + return table[eventName].flatMap((e) => Array.isArray(e && e.hooks) ? e.hooks : []); +} + +// ─── Step 1: Export surface check ──────────────────────────────────────────── + +describe('#3426 — export surface: buildCodexHookWindowsShimIR must be exported', () => { + test('buildCodexHookWindowsShimIR is a function', () => { + assert.equal(typeof buildCodexHookWindowsShimIR, 'function', + 'buildCodexHookWindowsShimIR must be exported from runtime-hooks-surface.cjs'); + }); + + test('ensureCodexHooksJsonSessionStart is a function', () => { + assert.equal(typeof ensureCodexHooksJsonSessionStart, 'function', + 'ensureCodexHooksJsonSessionStart must be exported from runtime-hooks-surface.cjs'); + }); +}); + +// ─── Step 2: Typed IR shape for Windows Codex hook shim ────────────────────── + +describe('#3426 — buildCodexHookWindowsShimIR: typed IR (not rendered text)', () => { + const FAKE_SCRIPT = 'C:/Users/me/.codex/hooks/gsd-check-update.js'; + const FAKE_RUNNER = '"C:/Program Files/nodejs/node.exe"'; + + test('returns typed IR with invocation, cmdPath, and render factory', () => { + const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, FAKE_RUNNER); + // IR shape assertion — per CONTRIBUTING.md L558 IR-first discipline + assert.ok(ir && typeof ir === 'object', 'must return an object'); + assert.ok(typeof ir.invocation === 'object', 'must have invocation record'); + assert.ok(typeof ir.cmdPath === 'string', 'must have cmdPath string'); + assert.ok(typeof ir.hookCommand === 'string', 'must have hookCommand string (written to hooks.json)'); + assert.ok(typeof ir.render === 'object', 'must have render factory'); + assert.ok(typeof ir.render.cmd === 'function', 'must have render.cmd() factory'); + }); + + test('invocation.target equals the resolved script path', () => { + const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, FAKE_RUNNER); + // invocation.target is the JS file being wrapped — same IR contract as buildWindowsShimTriple + assert.ok( + ir.invocation.target.includes('gsd-check-update.js'), + `invocation.target must reference the hook script, got: ${ir.invocation.target}`, + ); + }); + + test('invocation.interpreter is the node runner (not bash)', () => { + const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, FAKE_RUNNER); + // The shim must invoke node, never bash — bash is not a valid Codex hook runner on Windows + const interp = ir.invocation.interpreter; + assert.ok( + typeof interp === 'string' && (interp.includes('node') || interp === 'node'), + `invocation.interpreter must be a node path, not bash. Got: ${interp}`, + ); + assert.ok( + !interp.toLowerCase().includes('bash'), + `invocation.interpreter must NOT be bash — bash is the source of the #3426 failure. Got: ${interp}`, + ); + }); + + test('cmdPath ends with .cmd extension', () => { + const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, FAKE_RUNNER); + assert.ok( + ir.cmdPath.endsWith('.cmd'), + `cmdPath must end with .cmd for cmd.exe native execution, got: ${ir.cmdPath}`, + ); + }); + + test('hookCommand is the .cmd path (not a "runner script.js" string)', () => { + const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, FAKE_RUNNER); + // The hook command written to hooks.json must be the .cmd path, not "node.exe script.js" + // because cmd.exe executes .cmd natively without POSIX exec layer + assert.ok( + ir.hookCommand.includes('.cmd'), + `hookCommand must reference the .cmd shim, got: ${ir.hookCommand}`, + ); + // hookCommand must NOT contain bash — this was the failure mode + assert.ok( + !ir.hookCommand.toLowerCase().includes('bash'), + `hookCommand must NOT reference bash, got: ${ir.hookCommand}`, + ); + }); + + test('returns null when absoluteRunnerToken is null (caller skips registration)', () => { + const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, null); + assert.equal(ir, null, + 'must return null when runner is unavailable so caller can warn-and-skip'); + }); +}); + +// ─── Step 2b: Typed IR — eol / quoting / passthroughArgs ───────────────────── +// Per CONTRIBUTING.md L558-L565: assert on the typed IR, not on rendered text. +// These assertions cover the three bug-critical render semantics that +// text-matching tests would miss (silent EOL/quoting/passthrough regressions). + +describe('#3426 — buildCodexHookWindowsShimIR: typed IR eol / quoting / passthroughArgs', () => { + const FAKE_SCRIPT = 'C:/Users/me/.codex/hooks/gsd-check-update.js'; + const FAKE_RUNNER = '"C:/Program Files/nodejs/node.exe"'; + + test('eol.cmd is CRLF (\\r\\n) — canonical for cmd.exe .cmd files', () => { + const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, FAKE_RUNNER); + assert.ok(ir && typeof ir.eol === 'object', 'IR must expose an eol field'); + assert.strictEqual( + ir.eol.cmd, + '\r\n', + 'eol.cmd must be CRLF (\\r\\n) — LF-only .cmd files risk silent parse failures on some Windows versions', + ); + }); + + test('invocation.target has no shell-metachar leakage (clean absolute path)', () => { + const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, FAKE_RUNNER); + const target = ir.invocation.target; + assert.ok(typeof target === 'string' && target.length > 0, 'invocation.target must be a non-empty string'); + // The target stored in the IR is the raw unquoted path — quoting happens at + // render time. A metachar in the raw value means the IR is already corrupted. + assert.ok( + !target.includes('"') && !target.includes("'") && !target.includes('`'), + `invocation.target must be the raw path without shell quoting, got: ${target}`, + ); + assert.ok( + target.endsWith('.js'), + `invocation.target must resolve to the .js script, got: ${target}`, + ); + }); + + test('passthroughArgs is true — shim forwards all args via %*', () => { + const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, FAKE_RUNNER); + assert.strictEqual( + ir.passthroughArgs, + true, + 'passthroughArgs must be true: the .cmd shim must forward all arguments to the node script via %*', + ); + }); +}); + +// ─── Step 3: Counter-test — non-Windows platforms use node-runner command ──── + +describe('#3426 counter-test: darwin/linux Codex paths use node-runner command (not .cmd shim)', () => { + test('projectManagedHookCommand on darwin emits node-runner command, not .cmd', () => { + const runner = resolveNodeRunner() || '"/usr/local/bin/node"'; + const cmd = projectManagedHookCommand({ + absoluteRunner: runner, + scriptPath: '/Users/me/.codex/hooks/gsd-check-update.js', + runtime: 'codex', + platform: 'darwin', + }); + assert.ok(typeof cmd === 'string', 'must return a string on darwin'); + assert.ok(!cmd.endsWith('.cmd'), 'darwin command must NOT reference a .cmd shim'); + assert.ok( + cmd.includes('gsd-check-update.js'), + `darwin command must reference the .js hook directly, got: ${cmd}`, + ); + }); + + test('projectManagedHookCommand on linux emits node-runner command, not .cmd', () => { + const runner = resolveNodeRunner() || '"/usr/local/bin/node"'; + const cmd = projectManagedHookCommand({ + absoluteRunner: runner, + scriptPath: '/home/me/.codex/hooks/gsd-check-update.js', + runtime: 'codex', + platform: 'linux', + }); + assert.ok(typeof cmd === 'string', 'must return a string on linux'); + assert.ok(!cmd.endsWith('.cmd'), 'linux command must NOT reference a .cmd shim'); + assert.ok( + cmd.includes('gsd-check-update.js'), + `linux command must reference the .js hook directly, got: ${cmd}`, + ); + }); +}); + +// ─── Step 4: Integration — ensureCodexHooksJsonSessionStart on win32 writes .cmd shim ── + +describe('#3426 integration: ensureCodexHooksJsonSessionStart on win32 writes .cmd shim', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempDir('gsd-3426-'); + fs.mkdirSync(path.join(tmpDir, 'hooks'), { recursive: true }); + // Stub the hook file that must exist for the hook to be registered + fs.writeFileSync( + path.join(tmpDir, 'hooks', 'gsd-check-update.js'), + '#!/usr/bin/env node\nconsole.log("ok");\n', + ); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('win32: hooks.json command references .cmd shim (not "node.exe script.js")', () => { + const fakeRunner = '"C:/Program Files/nodejs/node.exe"'; + + const result = ensureCodexHooksJsonSessionStart(tmpDir, { + absoluteRunner: fakeRunner, + platform: 'win32', + }); + + assert.ok(result.wrote || result.changed, 'must write hooks.json on win32'); + + const hooksJsonPath = path.join(tmpDir, 'hooks.json'); + assert.ok(fs.existsSync(hooksJsonPath), 'hooks.json must exist after install'); + + const hooksJson = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8')); + // #1348: hooks.json is now always written in nested { hooks: { ... } } shape + const commands = hookHandlersForEvent(hooksJson, 'SessionStart') + .map((h) => h && h.command) + .filter((c) => typeof c === 'string'); + + assert.ok(commands.length > 0, 'must have at least one SessionStart hook command'); + + const cmd = commands.find((c) => c.includes('gsd-check-update')); + assert.ok(cmd, 'must have a gsd-check-update hook command'); + + // KEY ASSERTION: on win32, the command must reference a .cmd file — not bash + assert.ok( + cmd.includes('.cmd'), + `win32 hook command must reference a .cmd shim to avoid bash.exe exec failure (#3426). Got: ${cmd}`, + ); + assert.ok( + !cmd.toLowerCase().includes('bash'), + `win32 hook command must NOT reference bash.exe — this was the #3426 failure. Got: ${cmd}`, + ); + }); + + test('win32: .cmd shim file is written to the hooks directory', () => { + const fakeRunner = '"C:/Program Files/nodejs/node.exe"'; + + ensureCodexHooksJsonSessionStart(tmpDir, { + absoluteRunner: fakeRunner, + platform: 'win32', + }); + + const cmdShimPath = path.join(tmpDir, 'hooks', 'gsd-check-update.cmd'); + assert.ok( + fs.existsSync(cmdShimPath), + `win32: .cmd shim must be written at ${cmdShimPath}`, + ); + // File must be non-empty — structure check only (IR-first discipline) + const size = fs.statSync(cmdShimPath).size; + assert.ok(size > 0, '.cmd shim must have non-zero content'); + }); + + test('non-Windows (darwin): hooks.json command is "node.exe script.js" (no .cmd shim)', () => { + const fakeRunner = '"/usr/local/bin/node"'; + + const result = ensureCodexHooksJsonSessionStart(tmpDir, { + absoluteRunner: fakeRunner, + platform: 'darwin', + }); + + assert.ok(result.wrote || result.changed, 'must write hooks.json on darwin'); + + const hooksJson = JSON.parse( + fs.readFileSync(path.join(tmpDir, 'hooks.json'), 'utf8'), + ); + // #1348: hooks.json is now always written in nested { hooks: { ... } } shape + const commands = hookHandlersForEvent(hooksJson, 'SessionStart') + .map((h) => h && h.command) + .filter((c) => typeof c === 'string'); + + const cmd = commands.find((c) => c.includes('gsd-check-update')); + assert.ok(cmd, 'must have a gsd-check-update hook command on darwin'); + + // Counter-test: darwin must NOT use a .cmd shim + assert.ok( + !cmd.endsWith('.cmd'), + `darwin hook command must NOT reference a .cmd shim, got: ${cmd}`, + ); + assert.ok( + cmd.includes('gsd-check-update.js'), + `darwin hook command must reference the .js file directly, got: ${cmd}`, + ); + + // .cmd shim must NOT be written on darwin + const cmdShimPath = path.join(tmpDir, 'hooks', 'gsd-check-update.cmd'); + assert.ok( + !fs.existsSync(cmdShimPath), + 'darwin must NOT write a .cmd shim', + ); + }); + + test('non-Windows (linux): same as darwin — no .cmd shim', () => { + const fakeRunner = '"/usr/local/bin/node"'; + + ensureCodexHooksJsonSessionStart(tmpDir, { + absoluteRunner: fakeRunner, + platform: 'linux', + }); + + const hooksJson = JSON.parse( + fs.readFileSync(path.join(tmpDir, 'hooks.json'), 'utf8'), + ); + // #1348: hooks.json is now always written in nested { hooks: { ... } } shape + const commands = hookHandlersForEvent(hooksJson, 'SessionStart') + .map((h) => h && h.command) + .filter((c) => typeof c === 'string'); + + const cmd = commands.find((c) => c.includes('gsd-check-update')); + assert.ok(cmd, 'linux must have a gsd-check-update hook command'); + assert.ok(!cmd.endsWith('.cmd'), 'linux must NOT use a .cmd shim'); + + const cmdShimPath = path.join(tmpDir, 'hooks', 'gsd-check-update.cmd'); + assert.ok(!fs.existsSync(cmdShimPath), 'linux must NOT write a .cmd shim'); + }); +}); + +// ─── Step 5: Uninstall cleanup — .cmd shim removed from disk ───────────────── + +describe('#3426 uninstall: gsd-check-update.cmd is removed from hooks dir on uninstall', () => { + let tmpDir; + + 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; + } + } + + beforeEach(() => { + tmpDir = createTempDir('gsd-3426-uninstall-'); + fs.mkdirSync(path.join(tmpDir, 'hooks'), { recursive: true }); + // Write the .js hook (required by install) and a pre-existing .cmd shim + fs.writeFileSync( + path.join(tmpDir, 'hooks', 'gsd-check-update.js'), + '#!/usr/bin/env node\nconsole.log("ok");\n', + ); + fs.writeFileSync( + path.join(tmpDir, 'hooks', 'gsd-check-update.cmd'), + '@ECHO OFF\r\n@SETLOCAL\r\n@"C:/node.exe" "C:/path/gsd-check-update.js" %*\r\n', + ); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('uninstall removes gsd-check-update.cmd from hooks directory', () => { + const cmdShimPath = path.join(tmpDir, 'hooks', 'gsd-check-update.cmd'); + assert.ok(fs.existsSync(cmdShimPath), 'pre-condition: .cmd shim exists before uninstall'); + + withCodexHome(tmpDir, () => uninstall(true, 'codex')); + + assert.ok( + !fs.existsSync(cmdShimPath), + `gsd-check-update.cmd must be removed from disk on uninstall — orphaned .cmd shim would cause stale hook references. Path: ${cmdShimPath}`, + ); + }); +}); + +// ─── Step 6: Upgrade path — existing win32 hooks.json with node-runner command ─ + +describe('#3426 upgrade: reinstall on win32 migrates existing "node script.js" to .cmd shim', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempDir('gsd-3426-upgrade-'); + fs.mkdirSync(path.join(tmpDir, 'hooks'), { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, 'hooks', 'gsd-check-update.js'), + '#!/usr/bin/env node\nconsole.log("ok");\n', + ); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('replaces old "node.exe script.js" command with .cmd shim on win32 reinstall', () => { + const managedHookPath = path.join(tmpDir, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/'); + // Pre-existing stale hooks.json with node-runner command (v1.42.3 shape) + const staleLegacyCommand = `"C:/Program Files/nodejs/node.exe" "${managedHookPath}"`; + fs.writeFileSync( + path.join(tmpDir, 'hooks.json'), + JSON.stringify({ + SessionStart: [{ hooks: [{ type: 'command', command: staleLegacyCommand }] }], + }, null, 2), + ); + + const fakeRunner = '"C:/Program Files/nodejs/node.exe"'; + ensureCodexHooksJsonSessionStart(tmpDir, { + absoluteRunner: fakeRunner, + platform: 'win32', + }); + + const hooksJson = JSON.parse(fs.readFileSync(path.join(tmpDir, 'hooks.json'), 'utf8')); + // #1348: hooks.json is now always written in nested { hooks: { ... } } shape + const commands = hookHandlersForEvent(hooksJson, 'SessionStart') + .map((h) => h && h.command) + .filter((c) => typeof c === 'string'); + + const gsdCmds = commands.filter((c) => c.includes('gsd-check-update')); + // Exactly one managed hook after migration — no duplicates + assert.equal(gsdCmds.length, 1, `must have exactly 1 gsd-check-update command after migration, got: ${JSON.stringify(gsdCmds)}`); + + // Must be the .cmd shim + assert.ok( + gsdCmds[0].includes('.cmd'), + `migrated command must reference .cmd shim, got: ${gsdCmds[0]}`, + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3427-3433-codex-install-shape.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3427-3433-codex-install-shape (consolidation epic #1969 B1 #1970)", () => { +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +const { describe, test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const { runNode } = require('./helpers/process-seam.cjs'); +const { throwIfFailed } = require('./helpers/git-fixture.cjs'); + +const { install, uninstall, parseTomlToObject } = require('../bin/install.js'); +const { createTempDir, cleanup, parseFrontmatter } = require('./helpers.cjs'); + +const HOOKS_DIST = path.join(__dirname, '..', 'hooks', 'dist'); +const BUILD_HOOKS_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); +// scripts/build-hooks.js copies pre-built hook files into hooks/dist and +// syntax-checks them with vm — it does not compile/bundle anything. See +// tests/helpers/timeouts.cjs for the class-norm justification. +const { BUILD_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); + +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 `/.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; + } +} + +function extractSessionStartCommandsFromHooksJson(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) return []; + const table = (value.hooks && typeof value.hooks === 'object' && !Array.isArray(value.hooks)) + ? value.hooks + : value; + const sessionStart = Array.isArray(table.SessionStart) ? table.SessionStart : []; + return sessionStart.flatMap((entry) => { + const hooks = entry && Array.isArray(entry.hooks) ? entry.hooks : []; + return hooks.map((h) => h && h.command).filter((cmd) => typeof cmd === 'string'); + }); +} + +describe('#3427 + #3433 — Codex installer avoids duplicate skills and mixed hook representation', { concurrency: false }, () => { + let tmpRoot; + let codexHome; + + beforeEach(() => { + if (!fs.existsSync(HOOKS_DIST) || fs.readdirSync(HOOKS_DIST).length === 0) { + throwIfFailed( + runNode([BUILD_HOOKS_SCRIPT], { timeoutMs: BUILD_TIMEOUT_MS }), + `node ${BUILD_HOOKS_SCRIPT}`, + ); + } + tmpRoot = createTempDir('gsd-3427-3433-'); + codexHome = path.join(tmpRoot, '.codex'); + fs.mkdirSync(codexHome, { recursive: true }); + }); + + afterEach(() => { + cleanup(tmpRoot); + }); + + test('regenerates managed gsd-* skill copies and preserves unrelated user skills (#3562 reverses prior #3427/#3433 behaviour)', () => { + // Stale legacy body — fresh install must overwrite this so Codex sees the + // current SKILL.md, not whatever was last on disk. + const legacySkillBody = '# old managed\n'; + fs.mkdirSync(path.join(codexHome, 'skills', 'gsd-help'), { recursive: true }); + fs.writeFileSync(path.join(codexHome, 'skills', 'gsd-help', 'SKILL.md'), legacySkillBody); + const legacyHash = crypto.createHash('sha256').update(legacySkillBody).digest('hex'); + fs.writeFileSync(path.join(codexHome, 'gsd-file-manifest.json'), JSON.stringify({ + version: 1, + files: { + 'skills/gsd-help/SKILL.md': legacyHash, + }, + }, null, 2)); + + fs.mkdirSync(path.join(codexHome, 'skills', 'custom-user-skill'), { recursive: true }); + fs.writeFileSync(path.join(codexHome, 'skills', 'custom-user-skill', 'SKILL.md'), '# user skill\n'); + + withCodexHome(codexHome, () => install(true, 'codex')); + + // #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 + // .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'); + + // #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', () => { + const configToml = [ + '[features]', + 'codex_hooks = true', + '', + '[[hooks.SessionStart]]', + '[[hooks.SessionStart.hooks]]', + 'type = "command"', + 'command = "node /tmp/legacy/.codex/hooks/gsd-check-update.js"', + '', + ].join('\n'); + fs.writeFileSync(path.join(codexHome, 'config.toml'), configToml); + + fs.writeFileSync(path.join(codexHome, 'hooks.json'), JSON.stringify({ + SessionStart: [ + { + hooks: [ + { type: 'command', command: 'node "/Users/example/bin/user-hook.js"' }, + ], + }, + ], + }, null, 2)); + + withCodexHome(codexHome, () => install(true, 'codex')); + + const parsedToml = parseTomlToObject(fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8')); + const tomlSessionStart = parsedToml.hooks?.SessionStart ?? []; + const tomlCommands = tomlSessionStart.flatMap((entry) => + (Array.isArray(entry?.hooks) ? entry.hooks : []).map((hook) => hook.command).filter((cmd) => typeof cmd === 'string') + ); + assert.equal(tomlCommands.some((cmd) => cmd.includes('gsd-check-update.js')), false); + + const hooksJson = JSON.parse(fs.readFileSync(path.join(codexHome, 'hooks.json'), 'utf8')); + const sessionStartCommands = extractSessionStartCommandsFromHooksJson(hooksJson); + const gsdCommands = sessionStartCommands.filter((cmd) => cmd.includes('gsd-check-update')); + + assert.equal(gsdCommands.length, 1); + assert.equal(sessionStartCommands.includes('node "/Users/example/bin/user-hook.js"'), true); + }); + + test('uninstall removes managed SessionStart hook from hooks.json but preserves user hooks', () => { + const hooksDir = path.join(codexHome, 'hooks'); + fs.mkdirSync(hooksDir, { recursive: true }); + fs.writeFileSync(path.join(hooksDir, 'gsd-check-update.js'), '// managed hook\n'); + const managedHookPath = path.join(codexHome, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/'); + + fs.writeFileSync(path.join(codexHome, 'hooks.json'), JSON.stringify({ + SessionStart: [ + { + hooks: [ + { type: 'command', command: `node "${managedHookPath}"` }, + { type: 'command', command: 'node "/Users/example/bin/user-hook.js"' }, + ], + }, + ], + }, null, 2)); + + withCodexHome(codexHome, () => uninstall(true, 'codex')); + + const hooksJson = JSON.parse(fs.readFileSync(path.join(codexHome, 'hooks.json'), 'utf8')); + const sessionStartCommands = extractSessionStartCommandsFromHooksJson(hooksJson); + // On Windows the managed hook is the .cmd shim path; on POSIX it is the .js node-runner command. + // Either way the managed hook is gone after uninstall — only the user hook remains. + const gsdCommands = sessionStartCommands.filter((cmd) => cmd.includes('gsd-check-update')); + + assert.equal(gsdCommands.length, 0); + assert.equal(sessionStartCommands.includes('node "/Users/example/bin/user-hook.js"'), true); + }); +}); + }); +} diff --git a/tests/codex-config-install.test.cjs b/tests/codex-config-install.test.cjs new file mode 100644 index 000000000..0d4361bc3 --- /dev/null +++ b/tests/codex-config-install.test.cjs @@ -0,0 +1,2894 @@ +/** + * GSD Tools Tests - codex-config.cjs + * + * Tests for Codex adapter header, agent conversion, config.toml generation/merge, + * per-agent .toml generation, and uninstall cleanup. + */ + +// Enable test exports from install.js (skips main CLI logic) +process.env.GSD_TEST_MODE = '1'; + +const { test, describe, before, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { runNode } = require('./helpers/process-seam.cjs'); +const { throwIfFailed } = require('./helpers/git-fixture.cjs'); +const { cleanup } = require('./helpers.cjs'); +const fc = require('fast-check'); +const { CLAUDE_AGENT_ALIASES: _CLAUDE_AGENT_ALIASES } = require('../gsd-core/bin/lib/model-resolver.cjs'); +const { escapeRegex: _escapeRegex } = require('../gsd-core/bin/lib/pattern.cjs'); +// #3241 — the intended new home for CLAUDE_AGENT_ALIASES + isAnthropicFlavoredModel +// (see .gsd/phase/feat-3241-codex-omit-model-by-default/40-design.md "The seam +// decision"). Neither export exists on model-catalog.cjs yet; requiring the +// module does not throw (it just has no such keys today), but calling +// isAnthropicFlavoredModel does — see the new describe block below. +const _modelCatalog = require('../gsd-core/bin/lib/model-catalog.cjs'); +const _modelResolver = require('../gsd-core/bin/lib/model-resolver.cjs'); + +// #2153 follow-up: ensure hooks/dist/ exists before any install integration +// test runs. The Codex install path copies hook files from hooks/dist/, which +// is gitignored and only populated by `npm run build:hooks`. When one of the +// codex-config*.test.cjs files is run in isolation (`node --test +// tests/codex-config-agents.test.cjs`, for example) the build step from the +// npm-test pretest chain does not run, and the "Codex install copies hook +// file" regression silently fails because hooks/dist/ is empty. +// Build on demand so the test passes regardless of runner ordering. +const HOOKS_DIST = path.join(__dirname, '..', 'hooks', 'dist'); +const BUILD_HOOKS_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); +// scripts/build-hooks.js copies pre-built hook files into hooks/dist and +// syntax-checks them with vm — it does not compile/bundle anything. See +// tests/helpers/timeouts.cjs for the class-norm justification. +const { BUILD_TIMEOUT_MS: BUILD_HOOKS_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); +before(() => { + if (!fs.existsSync(HOOKS_DIST) || fs.readdirSync(HOOKS_DIST).length === 0) { + throwIfFailed( + runNode([BUILD_HOOKS_SCRIPT], { timeoutMs: BUILD_HOOKS_TIMEOUT_MS }), + `node ${BUILD_HOOKS_SCRIPT}`, + ); + } +}); + +const { + getCodexSkillAdapterHeader: _getCodexSkillAdapterHeader, + convertClaudeAgentToCodexAgent: _convertClaudeAgentToCodexAgent, + convertClaudeCommandToCodexSkill: _convertClaudeCommandToCodexSkill, + generateCodexAgentToml: _generateCodexAgentToml, + _resetCodexWarningDedupeForTests: __resetCodexWarningDedupeForTests, + cleanupCodexSkillMetadataSidecars: _cleanupCodexSkillMetadataSidecars, + generateCodexConfigBlock: _generateCodexConfigBlock, + stripGsdFromCodexConfig: _stripGsdFromCodexConfig, + migrateCodexHooksMapFormat: _migrateCodexHooksMapFormat, + mergeCodexConfig: _mergeCodexConfig, + install, + GSD_CODEX_MARKER: _GSD_CODEX_MARKER, + deriveCodexSandboxMode: _deriveCodexSandboxMode, + // #3897 rung 3 (ADR-3473 §8.3, option 2 — HALT.md): anticipated new export + // holding the 17 explicit read-only pins for roles whose tool contract would + // otherwise derive workspace-write (16 measured by HALT.md + gsd-nyquist-auditor, + // surfaced by the list-form parse fix). Does not exist on the current tree — + // destructuring a non-existent key is `undefined`, not a throw, so requiring + // this module still succeeds; every test below that touches it fails on its + // own `typeof` guard instead. + CODEX_SANDBOX_HOLDS: _CODEX_SANDBOX_HOLDS, + parseTomlToObject: _parseTomlToObject, + validateCodexConfigSchema: _validateCodexConfigSchema, + uninstall, + CODEX_EXTENDED_HOOK_EVENTS, +} = require('../bin/install.js'); + +const { resolveNodeRunner } = require('../gsd-core/bin/lib/runtime-hooks-surface.cjs'); +const { resolveInstallPlan: _resolveInstallPlan } = require('../gsd-core/bin/lib/runtime-config-adapter-registry.cjs'); +// #3897 fixup: deriveCodexSandboxMode's 2nd param is now the already-resolved +// `tools:` frontmatter VALUE, not raw agent content (codex-agent-toml.cjs no +// longer parses frontmatter at all — no third copy of that extraction). +const { + extractFrontmatterAndBody: _extractFrontmatterAndBody, + extractFrontmatterField: _extractFrontmatterField, +} = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs'); +// #3897 list-form parse fix: the ONE shared `tools:`-value reader both +// sandbox-feeding production paths (`bin/install.js`'s `generateCodexAgentToml` +// and `agent-install-check.cts`'s `checkCodexSandboxPosture`) now route +// through — handles inline (`tools: Read, Write`) AND YAML block-list +// (`tools:` + indented `- Item` lines) form. Used below by `realAgentToolsRaw` +// so the test's own measurement of "what does this role's tool contract +// declare" cannot silently disagree with production (the exact generative- +// fix-divergence shape this fix closes). +const { extractToolsValue: _extractToolsValue } = require('../gsd-core/bin/lib/codex-agent-toml.cjs'); + +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 (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'); +} + +function _writeCodexConfig(codexHome, content) { + fs.mkdirSync(codexHome, { recursive: true }); + fs.writeFileSync(path.join(codexHome, 'config.toml'), content, 'utf8'); +} + +function _readHooksSessionStartCommands(codexHome) { + const hooksPath = path.join(codexHome, 'hooks.json'); + if (!fs.existsSync(hooksPath)) return []; + const raw = fs.readFileSync(hooksPath, 'utf8').trim(); + if (!raw) return []; + const parsed = JSON.parse(raw); + const table = (parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks)) + ? parsed.hooks + : parsed; + const sessionStart = Array.isArray(table.SessionStart) ? table.SessionStart : []; + return sessionStart.flatMap((entry) => [ + ...(typeof entry?.command === 'string' ? [entry.command] : []), + ...(Array.isArray(entry?.hooks) + ? entry.hooks.map((hook) => hook && hook.command).filter((cmd) => typeof cmd === 'string') + : []), + ]); +} + +function _countMatches(content, pattern) { + return (content.match(pattern) || []).length; +} + +function _assertNoDraftRootKeys(content) { + assert.ok(!content.includes('model = "gpt-5.6-terra"'), 'does not inject draft model default'); + assert.ok(!content.includes('model_reasoning_effort = "high"'), 'does not inject draft reasoning default'); + assert.ok(!content.includes('disable_response_storage = true'), 'does not inject draft storage default'); +} + +function _assertUsesOnlyEol(content, eol) { + if (eol === '\r\n') { + assert.ok(content.includes('\r\n'), 'contains CRLF line endings'); + assert.ok(!content.replace(/\r\r?\n/g, '').includes('\n'), 'does not contain bare LF line endings'); + return; + } + assert.ok(!content.includes('\r\n'), 'does not contain CRLF line endings'); +} + +function _assertNoCodexBareGsdToolsInvocation(content, label) { + const patterns = [ + /(^|\r?\n)[ \t]*gsd-tools\s/, + /\$\(\s*gsd-tools\s/, + /`\s*gsd-tools\s/, + /(?:&&|\|\||[;|])\s*gsd-tools\s/, + ]; + for (const pattern of patterns) { + assert.doesNotMatch( + content, + pattern, + `${label} must not contain a command-position bare gsd-tools invocation`, + ); + } +} + +// ─── getCodexSkillAdapterHeader ───────────────────────────────────────────────── + + + + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3562-codex-install-skill-surface.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3562-codex-install-skill-surface (consolidation epic #1969 B1 #1970)", () => { +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +/** + * Regression test for bug #3562 — Codex global install must create a + * discoverable $gsd-* skill surface. + * + * Codex CLI 0.130.0 (the version in the issue report) does NOT auto-discover + * commands from gsd-core/workflows/*.md or agents/*.md. It only registers + * commands from skills//SKILL.md. Prior installer logic ("Codex now + * discovers official skills from .agents/skills") was based on an assumption + * that does not match the shipping Codex CLI behavior, leaving users with + * workflows on disk and no $gsd-* entrypoints after `npx @opengsd/gsd-core + * --codex --global`. + * + * Fix: re-wire copyCommandsAsCodexSkills() back into the install dispatch path + * so the same skill-shape that Claude / Copilot / Antigravity / Cursor / + * Windsurf / Augment / Trae installs produce is also produced for Codex. + */ + +const { describe, test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { runNode } = require('./helpers/process-seam.cjs'); +const { throwIfFailed } = require('./helpers/git-fixture.cjs'); + +const { install } = require('../bin/install.js'); +const { createTempDir, cleanup, parseFrontmatter } = require('./helpers.cjs'); + +const HOOKS_DIST = path.join(__dirname, '..', 'hooks', 'dist'); +const BUILD_HOOKS_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); +// scripts/build-hooks.js copies pre-built hook files into hooks/dist and +// syntax-checks them with vm — it does not compile/bundle anything. See +// tests/helpers/timeouts.cjs for the class-norm justification. +const { BUILD_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); + +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 `/.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; + } +} + +describe('#3562 — Codex install produces discoverable $gsd-* skill surface', { concurrency: false }, () => { + let tmpRoot; + let codexHome; + + beforeEach(() => { + if (!fs.existsSync(HOOKS_DIST) || fs.readdirSync(HOOKS_DIST).length === 0) { + throwIfFailed( + runNode([BUILD_HOOKS_SCRIPT], { timeoutMs: BUILD_TIMEOUT_MS }), + `node ${BUILD_HOOKS_SCRIPT}`, + ); + } + tmpRoot = createTempDir('gsd-3562-'); + codexHome = path.join(tmpRoot, '.codex'); + fs.mkdirSync(codexHome, { recursive: true }); + }); + + afterEach(() => { + cleanup(tmpRoot); + }); + + test('global install creates skills/gsd-help/SKILL.md', () => { + withCodexHome(codexHome, () => install(true, 'codex')); + + // #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. ` + + 'Without this, Codex CLI 0.130.0 does not expose any $gsd-* command.', + ); + }); + + test('SKILL.md content has frontmatter expected by Codex skill discovery', () => { + withCodexHome(codexHome, () => install(true, 'codex')); + + 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'); + const frontmatter = parseFrontmatter(content); + assert.equal(frontmatter.name, 'gsd-help', 'SKILL.md frontmatter must declare name: gsd-help so $gsd-help resolves'); + }); + + test('multiple core $gsd-* skills are produced (not just gsd-help)', () => { + withCodexHome(codexHome, () => install(true, 'codex')); + + const skillsDir = codexSkillsRoot(tmpRoot); + assert.ok(fs.existsSync(skillsDir), 'skills/ directory must exist after install'); + + const gsdSkills = fs + .readdirSync(skillsDir, { withFileTypes: true }) + .filter((e) => e.isDirectory() && e.name.startsWith('gsd-')) + .map((e) => e.name); + + // Lower bound — exact count depends on the current command surface. The + // commands/gsd/ directory holds dozens of *.md files; expecting more than + // 10 generated skills is a conservative floor that catches "we generated + // nothing" or "we only generated one accidentally" regressions. + assert.ok( + gsdSkills.length >= 10, + `Expected >= 10 generated gsd-* skill directories, found ${gsdSkills.length}: ${gsdSkills.join(', ')}`, + ); + }); + + test('install preserves existing user skills (does not remove unrelated dirs)', () => { + fs.mkdirSync(path.join(codexHome, 'skills', 'custom-user-skill'), { recursive: true }); + fs.writeFileSync( + path.join(codexHome, 'skills', 'custom-user-skill', 'SKILL.md'), + '---\nname: custom-user-skill\n---\n# user skill\n', + ); + + withCodexHome(codexHome, () => install(true, 'codex')); + + const userSkill = path.join(codexHome, 'skills', 'custom-user-skill', 'SKILL.md'); + assert.ok( + fs.existsSync(userSkill), + 'Codex install must preserve existing non-gsd user skill directories', + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3566-codex-hooks-feature-canonical-key.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3566-codex-hooks-feature-canonical-key (consolidation epic #1969 B1 #1970)", () => { +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +/** + * Regression tests for bug #3566 — Codex installer must emit canonical + * [features].hooks (not the legacy [features].codex_hooks). + * + * Codex itself marks `codex_hooks` as a `legacy_key` in + * codex-rs/features/src/legacy.rs. The canonical current feature flag is + * `hooks`. The GSD installer was still writing `codex_hooks` on every fresh + * install / reinstall, leaving deprecated config behind. This file pins: + * + * 1. Fresh install writes canonical `[features].hooks = true` and never + * emits `codex_hooks` (section, root-dotted, or block-fallback forms). + * 2. Reinstall over a GSD-owned section-form legacy + * `[features].codex_hooks = true` migrates forward to + * `[features].hooks = true` (legacy line removed); user-owned legacy + * entries are preserved per #2760. + * 3. Reinstall over a GSD-owned root-dotted legacy + * `features.codex_hooks = true` migrates forward to + * `features.hooks = true`; user-owned legacy entries are preserved. + * 4. Reinstall over a user-owned `[features].hooks = true` (no GSD + * ownership marker) preserves the user line; no double-write, no + * ownership stamp. + * 5. The `hasEnabledCodexHooksFeature` recognizer treats both canonical + * `hooks` AND legacy `codex_hooks` as "enabled" so existing installs + * keep working across the migration window. + * 6. Uninstall removes either GSD-owned `hooks` or GSD-owned legacy + * `codex_hooks`; user-owned `hooks` is preserved. + * + * All assertions use parseTomlToObject — never substring-match on raw TOML + * text (per RULESET.TESTS.no-source-grep). The product surface is the + * parsed config shape, not the file's lexical layout. + */ + +const { describe, test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { runNode } = require('./helpers/process-seam.cjs'); +const { throwIfFailed } = require('./helpers/git-fixture.cjs'); + +const { install, uninstall, parseTomlToObject } = require('../bin/install.js'); +const { createTempDir, cleanup } = require('./helpers.cjs'); + +const HOOKS_DIST = path.join(__dirname, '..', 'hooks', 'dist'); +const BUILD_HOOKS_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); +// scripts/build-hooks.js copies pre-built hook files into hooks/dist and +// syntax-checks them with vm — it does not compile/bundle anything. See +// tests/helpers/timeouts.cjs for the class-norm justification. +const { BUILD_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); + +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 `/.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; + } +} + +function readConfig(codexHome) { + const text = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8'); + return { text, parsed: parseTomlToObject(text) }; +} + +function featuresHooks(parsed) { + return parsed?.features?.hooks; +} + +function featuresCodexHooks(parsed) { + return parsed?.features?.codex_hooks; +} + +describe('#3566 — Codex feature flag is canonical "hooks" (not legacy "codex_hooks")', { concurrency: false }, () => { + let tmpRoot; + let codexHome; + + beforeEach(() => { + if (!fs.existsSync(HOOKS_DIST) || fs.readdirSync(HOOKS_DIST).length === 0) { + throwIfFailed( + runNode([BUILD_HOOKS_SCRIPT], { timeoutMs: BUILD_TIMEOUT_MS }), + `node ${BUILD_HOOKS_SCRIPT}`, + ); + } + tmpRoot = createTempDir('gsd-3566-'); + codexHome = path.join(tmpRoot, '.codex'); + fs.mkdirSync(codexHome, { recursive: true }); + }); + + afterEach(() => { + cleanup(tmpRoot); + }); + + test('fresh install writes [features].hooks = true and never emits codex_hooks', () => { + withCodexHome(codexHome, () => install(true, 'codex')); + const { parsed } = readConfig(codexHome); + + assert.strictEqual( + featuresHooks(parsed), + true, + 'fresh install must write canonical [features].hooks = true', + ); + assert.strictEqual( + featuresCodexHooks(parsed), + undefined, + 'fresh install must NOT write legacy [features].codex_hooks', + ); + }); + + test('install over a pre-existing legacy [features].codex_hooks line preserves it (user-owned, #2760 defensive)', () => { + // A user who hand-wrote `codex_hooks = true` keeps the legacy key. + // Codex itself maps it via the runtime legacy_key alias, so this is + // forward-compatible without GSD rewriting user-authored content. + const legacy = [ + '[features]', + 'codex_hooks = true', + '', + ].join('\n'); + fs.writeFileSync(path.join(codexHome, 'config.toml'), legacy); + + withCodexHome(codexHome, () => install(true, 'codex')); + const { parsed } = readConfig(codexHome); + + assert.strictEqual( + featuresCodexHooks(parsed), + true, + 'user-owned legacy codex_hooks line must be preserved verbatim', + ); + }); + + test('install over a pre-existing legacy root-dotted features.codex_hooks line preserves it', () => { + const legacy = 'features.codex_hooks = true\n'; + fs.writeFileSync(path.join(codexHome, 'config.toml'), legacy); + + withCodexHome(codexHome, () => install(true, 'codex')); + const { parsed } = readConfig(codexHome); + + assert.strictEqual( + featuresCodexHooks(parsed), + true, + 'user-owned root-dotted legacy line must be preserved verbatim', + ); + }); + + test('reinstall preserves user-owned [features].hooks = true (no GSD ownership marker)', () => { + const userOwned = [ + '[features]', + 'hooks = true', + '', + ].join('\n'); + fs.writeFileSync(path.join(codexHome, 'config.toml'), userOwned); + + withCodexHome(codexHome, () => install(true, 'codex')); + const { parsed } = readConfig(codexHome); + + assert.strictEqual( + featuresHooks(parsed), + true, + 'user-owned hooks=true must be preserved', + ); + }); + + test('uninstall removes GSD-owned canonical hooks line but preserves user-owned hooks', () => { + // Phase 1: fresh GSD install — writes GSD-owned hooks line. + withCodexHome(codexHome, () => install(true, 'codex')); + const { parsed: afterInstall } = readConfig(codexHome); + assert.strictEqual( + featuresHooks(afterInstall), + true, + 'precondition: install wrote canonical hooks', + ); + + withCodexHome(codexHome, () => uninstall(true, 'codex')); + const configPath = path.join(codexHome, 'config.toml'); + if (!fs.existsSync(configPath)) { + // Uninstall may delete config.toml entirely when nothing user-owned + // remains — that is the strongest possible "feature flag removed" + // signal and counts as success. + return; + } + const { parsed: afterUninstall } = readConfig(codexHome); + assert.notStrictEqual( + featuresHooks(afterUninstall), + true, + 'uninstall must remove GSD-owned canonical hooks line', + ); + }); + + test('uninstall preserves user-owned hooks=true when GSD never owned it', () => { + const userOwned = [ + '[features]', + 'hooks = true', + '', + ].join('\n'); + fs.writeFileSync(path.join(codexHome, 'config.toml'), userOwned); + + withCodexHome(codexHome, () => uninstall(true, 'codex')); + const { parsed } = readConfig(codexHome); + + assert.strictEqual( + featuresHooks(parsed), + true, + 'uninstall must NOT touch a hooks line GSD never claimed ownership of (#2760 defensive principle)', + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3582-codex-skills-materialized.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3582-codex-skills-materialized (consolidation epic #1969 B1 #1970)", () => { +/** + * Regression test for bug #3582 — Codex install must materialize the skill + * surface under `~/.codex/skills//SKILL.md`. + * + * Background: GSD 1.42.2 reported the user-visible failure + * > Skipped Codex skill-copy generation (Codex discovers official skills directly) + * which left users with a "successful" install but no routable `$gsd-*` + * entrypoints in Codex CLI 0.130.0. Codex CLI does NOT auto-discover + * commands from `~/.codex/gsd-core/workflows/*.md` or `agents/*.md`; + * it only registers slash commands derived from `~/.codex/skills//SKILL.md`. + * The "Codex discovers official skills directly" assumption was wrong. + * + * The current installer (#3562 / current main) calls + * `copyCommandsAsCodexSkills()` to materialize one SKILL.md per + * commands/gsd/*.md, with Claude-flavored command frontmatter rewritten + * into Codex skill frontmatter and the `` body + * produced by `getCodexSkillAdapterHeader()`. + * + * This test locks the install contract so the 1.42.2 regression cannot + * silently come back. It asserts the full expected skill-name set + * (deepStrictEqual, not just count), the full adapter block (using + * the exported `getCodexSkillAdapterHeader` IR as the expected value, + * not raw substring search), and the success/skip log invariant. + */ +// allow-test-rule: source-text-is-the-product (see #3582) +// This assertion validates the generated adapter block that is shipped to +// users in SKILL.md; matching exact emitted text is the contract under test. + +'use strict'; + +// GSD_TEST_MODE neutralizes side-effecting branches (auto-detection, etc.). +// Must be set BEFORE requiring bin/install.js; scoped to module load only +// so downstream tests don't see it. Mirrors the bug-2760 codex harness. +const previousGsdTestMode = process.env.GSD_TEST_MODE; +process.env.GSD_TEST_MODE = '1'; + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const { install, getCodexSkillAdapterHeader } = require('../bin/install.js'); +const { parseFrontmatter, createTempDir, cleanup } = require('./helpers.cjs'); + +if (previousGsdTestMode === undefined) { + delete process.env.GSD_TEST_MODE; +} else { + process.env.GSD_TEST_MODE = previousGsdTestMode; +} + +const ROOT = path.join(__dirname, '..'); +const COMMANDS_DIR = path.join(ROOT, 'commands', 'gsd'); + +// Strip ANSI color codes so log assertions don't depend on TTY detection. +function stripAnsi(s) { + // eslint-disable-next-line no-control-regex -- \x1b (ESC) is the required leading byte of ANSI SGR color sequences; matching it is the purpose of stripping ANSI codes from captured CLI/console output + return s.replace(/\x1b\[[0-9;]*m/g, ''); +} + +function assertNoBareGsdToolsInvocation(content, label) { + const patterns = [ + /(^|\n)[ \t]*gsd-tools\s/, + /\$\(\s*gsd-tools\s/, + /`\s*gsd-tools\s/, + /(?:&&|\|\||[;|])\s*gsd-tools\s/, + ]; + for (const pattern of patterns) { + assert.doesNotMatch( + content, + pattern, + `${label} must not contain a command-position bare gsd-tools invocation`, + ); + } +} + +/** + * Walk commands/gsd/**\/*.md and return the set of skill names the installer + * is contractually obligated to produce. Naming rule mirrors + * `copyCommandsAsCodexSkills` in bin/install.js: nested dirs collapse to + * `gsd--` with the .md stripped. + */ +function expectedSkillNames() { + const names = new Set(); + function recurse(dir, prefix) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory()) { + recurse(path.join(dir, entry.name), `${prefix}-${entry.name}`); + } else if (entry.name.endsWith('.md')) { + const base = entry.name.slice(0, -3); + names.add(`${prefix}-${base}`); + } + } + } + recurse(COMMANDS_DIR, 'gsd'); + return names; +} + +/** + * Run a Codex global install into a temp CODEX_HOME and capture stdout/stderr. + * Cleans up codexHome on throw so a partial-install failure never leaks + * temp directories. + */ +function runCodexInstallCaptured() { + const codexHome = createTempDir('gsd-3582-codex-'); + const logs = []; + const warnings = []; + const origLog = console.log; + const origWarn = console.warn; + console.log = (...a) => { logs.push(a.join(' ')); }; + console.warn = (...a) => { warnings.push(a.join(' ')); }; + + 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); + install(true, 'codex'); + return { codexHome, logs, warnings }; + } catch (err) { + // Always reclaim the temp dir if install throws — otherwise the + // describe-level afterEach can't see codexHome and it leaks. + try { cleanup(codexHome); } catch { /* best-effort */ } + throw err; + } finally { + process.chdir(previousCwd); + console.log = origLog; + console.warn = origWarn; + if (previousCodexHome === undefined) { + delete process.env.CODEX_HOME; + } 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 { + process.env.GSD_TEST_MODE = previousGsdTestMode; + } + } +} + +// concurrency:false — harness mutates console.* / process.env / process.cwd(). +// Matches the convention used by tests/bug-3562-codex-install-skill-surface.test.cjs. +describe('bug-3582: Codex global install materializes the skill surface', { concurrency: false }, () => { + let installRun; + + beforeEach(() => { + installRun = runCodexInstallCaptured(); + }); + + afterEach(() => { + if (installRun && installRun.codexHome) { + cleanup(installRun.codexHome); + } + }); + + test('writes the exact expected set of gsd-*/SKILL.md skills (deepEqual on name set)', () => { + const skillsDir = codexSkillsRoot(installRun.codexHome); + assert.ok( + fs.existsSync(skillsDir), + `Codex install must create ${skillsDir} (the 1.42.2 regression skipped this entirely)`, + ); + + const actualNames = fs.readdirSync(skillsDir, { withFileTypes: true }) + .filter(e => e.isDirectory() && e.name.startsWith('gsd-')) + .map(e => e.name); + + // deepStrictEqual on the sorted full set — not just count — so a + // partial install that drops a real command and substitutes a bogus + // same-count `gsd-*` directory cannot pass. + const expected = [...expectedSkillNames()].sort(); + assert.deepStrictEqual( + [...actualNames].sort(), + expected, + `installed Codex skills must exactly match commands/gsd/**/*.md (one skill per command)`, + ); + + // Every skill dir contains a non-empty SKILL.md file. Empty dirs or + // empty SKILL.md bodies would defeat Codex's slash-command + // registration as silently as the 1.42.2 "skipped" branch did. + for (const name of actualNames) { + const skillMd = path.join(skillsDir, name, 'SKILL.md'); + const stat = fs.statSync(skillMd); + assert.ok(stat.isFile(), `${skillMd} must be a regular file`); + assert.ok(stat.size > 0, `${skillMd} must not be empty`); + } + }); + + test('SKILL.md frontmatter declares hyphen-form name matching the directory', () => { + const skillsDir = codexSkillsRoot(installRun.codexHome); + const skillDirs = fs.readdirSync(skillsDir, { withFileTypes: true }) + .filter(e => e.isDirectory() && e.name.startsWith('gsd-')) + .map(e => e.name); + + for (const name of skillDirs) { + const content = fs.readFileSync( + path.join(skillsDir, name, 'SKILL.md'), + 'utf-8', + ); + // Uses the shared `parseFrontmatter` from tests/helpers.cjs per the + // CONTRIBUTING.md "tests parse, never grep" convention. + const fm = parseFrontmatter(content); + assert.strictEqual( + fm.name, + name, + `SKILL.md name field must match directory name for ${name} (got ${JSON.stringify(fm.name)})`, + ); + assert.ok( + typeof fm.description === 'string' && fm.description.length > 0, + `SKILL.md description must be a non-empty string for ${name}`, + ); + } + }); + + test('SKILL.md body contains the full block produced by the exported builder', () => { + // Structural check against the production builder's output — NOT a + // raw substring grep on the rendered file. `getCodexSkillAdapterHeader` + // is the typed IR exported by bin/install.js (#3582 PR #3609 codex + // review); the file on disk must contain its full output verbatim + // (open tag, body, closing ``). A truncated, + // empty, or missing-closing-tag adapter cannot satisfy this assertion. + const skillsDir = codexSkillsRoot(installRun.codexHome); + const skillDirs = fs.readdirSync(skillsDir, { withFileTypes: true }) + .filter(e => e.isDirectory() && e.name.startsWith('gsd-')) + .map(e => e.name); + + for (const name of skillDirs) { + const expectedAdapter = getCodexSkillAdapterHeader(name); + // Sanity: the builder itself must produce a closed block for the + // assertion below to be meaningful. + assert.ok( + expectedAdapter.startsWith(''), + `getCodexSkillAdapterHeader(${name}) must start with the opening tag`, + ); + assert.ok( + expectedAdapter.trimEnd().endsWith(''), + `getCodexSkillAdapterHeader(${name}) must end with the closing tag`, + ); + + const content = fs.readFileSync( + path.join(skillsDir, name, 'SKILL.md'), + 'utf-8', + ); + assert.ok( + content.includes(expectedAdapter), + `${name}/SKILL.md must contain the full adapter block produced by getCodexSkillAdapterHeader(${name}); Codex routes $${name} via this exact body`, + ); + } + }); + + test('representative skills named in the issue report are present', () => { + // The bug report and triage explicitly named these. Locking them as a + // representative set so a future dispatch / filter / profile change + // cannot drop just the commands the original user was trying to run. + const representative = [ + 'gsd-map-codebase', // the literal command from the bug report + 'gsd-execute-phase', + 'gsd-plan-phase', + 'gsd-new-project', + 'gsd-health', + ]; + const skillsDir = codexSkillsRoot(installRun.codexHome); + for (const name of representative) { + const skillMd = path.join(skillsDir, name, 'SKILL.md'); + assert.ok( + fs.existsSync(skillMd), + `${name}/SKILL.md must exist after Codex install (was unrouteable in 1.42.2)`, + ); + } + }); + + test('installed Codex skills do not ask agents to run bare gsd-tools commands', () => { + const skillsDir = codexSkillsRoot(installRun.codexHome); + const skillDirs = fs.readdirSync(skillsDir, { withFileTypes: true }) + .filter(e => e.isDirectory() && e.name.startsWith('gsd-')) + .map(e => e.name); + + for (const name of skillDirs) { + const content = fs.readFileSync( + path.join(skillsDir, name, 'SKILL.md'), + 'utf-8', + ); + assertNoBareGsdToolsInvocation(content, `${name}/SKILL.md`); + } + }); + + test('installer success log mentions skills/ — never claims success while skipping', () => { + // The 1.42.2 user-visible failure mode was a successful install that + // printed "Skipped Codex skill-copy generation (Codex discovers + // official skills directly)" while leaving the user with no + // entrypoints. Lock that the broken strings can NEVER coexist with a + // success indicator. Current main prints "✓ Installed N skills". + const cleanLogs = installRun.logs.map(stripAnsi); + const cleanWarnings = installRun.warnings.map(stripAnsi); + const allOutput = [...cleanLogs, ...cleanWarnings].join('\n'); + + assert.ok( + !/Skipped Codex skill-copy generation/i.test(allOutput), + `installer must never print "Skipped Codex skill-copy generation" (1.42.2 failure). Output:\n${allOutput}`, + ); + assert.ok( + !/Codex discovers official skills directly/i.test(allOutput), + `installer must never claim "Codex discovers official skills directly" (1.42.2 incorrect assumption). Output:\n${allOutput}`, + ); + + // Positive proof — at least one log line acknowledges the skills install. + const hasSkillsInstalledLog = cleanLogs.some(line => /Installed\s+\d+\s+skills\s+to\s+skills\//.test(line)); + assert.ok( + hasSkillsInstalledLog, + `installer must print a success line of the form "Installed N skills to skills/". Logs:\n${cleanLogs.join('\n')}`, + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3808-codex-adapter-text-mode-fallback.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3808-codex-adapter-text-mode-fallback (consolidation epic #1969 B1 #1970)", () => { +/** + * Regression test for bug #3808. + * + * When Codex runs in Default mode, `request_user_input` is reported as + * unavailable. The Codex skill adapter must tell the agent to activate the + * workflow's built-in TEXT_MODE mechanism (`--text` flag) rather than either: + * (a) silently picking a default value — the #3018 failure mode, or + * (b) ad-hoc plain-text fallback that bypasses the workflow's own branching. + * + * Workflows (e.g. plan-phase.md) already have TEXT_MODE logic: + * "Set TEXT_MODE=true if `--text` is present in $ARGUMENTS OR text_mode + * from init JSON is true." + * The adapter must tell the agent to USE that mechanism when + * `request_user_input` is unavailable instead of inventing its own fallback + * or silently continuing with defaults. + * + * Test design: mirrors the typed-semantic-flag pattern from bug #3018 so that + * prose rewording doesn't break tests as long as the semantics stay correct. + */ + +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); + +const INSTALL = require(path.join(__dirname, '..', 'bin', 'install.js')); +const { getCodexSkillAdapterHeader } = INSTALL; +const { tokenizeHeadings } = require('../gsd-core/bin/lib/markdown-sectionizer.cjs'); + +/** + * Extract the "Execute mode fallback" section text from the adapter header. + * Returns null if the section is missing. Section runs from the + * "Execute mode fallback:" label up to the next heading or tag. + */ +function extractExecuteModeFallback(header) { + const label = 'Execute mode fallback:'; + const labelIdx = header.indexOf(label); + if (labelIdx === -1) return null; + const bodyStart = header.indexOf('\n', labelIdx + label.length); + if (bodyStart === -1) return null; + + // End at whichever comes first: the next "## " heading (via the canonical + // heading tokenizer, not an ad-hoc regex) or the closing adapter tag. + const headings = tokenizeHeadings(header).filter((h) => h.level === 2 && h.offset > bodyStart); + const nextHeadingOffset = headings.length > 0 ? headings[0].offset - 1 : Infinity; // -1 for the leading \n + const closeTagIdx = header.indexOf('', bodyStart); + const closeTagOffset = closeTagIdx === -1 ? Infinity : closeTagIdx - 1; // -1 for the leading \n + const bodyEnd = Math.min(nextHeadingOffset, closeTagOffset); + if (bodyEnd === Infinity) return null; + + return header.slice(bodyStart + 1, bodyEnd).trim(); +} + +/** + * Parse the Execute-mode-fallback section into a typed semantic-flag record. + * + * Flags for bug #3808 (TEXT_MODE activation): + * activatesTextMode — does the prose tell the agent to activate TEXT_MODE / use --text? + * instructsStop — does the prose tell the agent to stop/halt/wait? + * presentsPlainText — does the prose mention plain-text / numbered-list presentation? + * silentlyPicksDefaults — (anti-pattern) does the prose instruct silent-default picking? + */ +function parseExecuteModeFallbackFor3808(section) { + if (!section || typeof section !== 'string') { + return { + ok: false, + sectionLength: 0, + activatesTextMode: false, + instructsStop: false, + presentsPlainText: false, + silentlyPicksDefaults: false, + }; + } + + const lower = section.toLowerCase(); + + // (a) TEXT_MODE activation — adapter must tell the agent to use the workflow's + // built-in text mode mechanism when request_user_input is unavailable. + // Accept either: explicit "--text" flag mention OR "text_mode" / "text mode" + // paired with context showing it is being SET/ACTIVATED (not just referenced). + const mentionsTextFlag = section.includes('--text'); + const mentionsTextModeOn = /text_mode\s*=\s*true|set\s+text_mode|activate\s+text.?mode|enable\s+text.?mode|text.?mode.*active|text.?mode.*on\b/i.test(section); + const activatesTextMode = mentionsTextFlag || mentionsTextModeOn; + + // (b) STOP/WAIT directive — the agent must halt instead of proceeding silently. + const instructsStop = /\b(stop|halt|wait)\b/.test(lower); + + // (c) Plain-text fallback presentation. + const presentsPlainText = /plain.?text|numbered list/.test(lower); + + // Anti-pattern guard — the prose that caused #3018 and resurfaces in #3808. + const silentlyPicksDefaults = /pick (a |the )?(reasonable|sensible|sane) default/i.test(section); + + return { + ok: true, + sectionLength: section.length, + activatesTextMode, + instructsStop, + presentsPlainText, + silentlyPicksDefaults, + }; +} + +describe('bug #3808: codex skill adapter activates TEXT_MODE when request_user_input is unavailable', () => { + const SKILL_NAMES = ['gsd-plan-phase', 'gsd-discuss-phase', 'gsd-execute-phase', 'gsd-verify-work']; + + test('getCodexSkillAdapterHeader is exported', () => { + assert.equal(typeof getCodexSkillAdapterHeader, 'function'); + }); + + test('Execute mode fallback section exists for all key skills', () => { + for (const skillName of SKILL_NAMES) { + const header = getCodexSkillAdapterHeader(skillName); + const section = extractExecuteModeFallback(header); + assert.ok(section !== null && section.length > 0, + `${skillName}: Execute mode fallback section must exist and have content`); + } + }); + + for (const skillName of SKILL_NAMES) { + test(`${skillName}: fallback activates TEXT_MODE (--text flag or text_mode=true) when request_user_input is unavailable`, () => { + const header = getCodexSkillAdapterHeader(skillName); + const section = extractExecuteModeFallback(header); + const parsed = parseExecuteModeFallbackFor3808(section); + assert.equal(parsed.activatesTextMode, true, + `${skillName}: fallback must instruct the agent to activate TEXT_MODE (mention --text flag or text_mode=true/active) when request_user_input is unavailable (#3808). Section was:\n${section}`); + }); + + test(`${skillName}: fallback instructs STOP/WAIT (not silent continuation)`, () => { + const header = getCodexSkillAdapterHeader(skillName); + const section = extractExecuteModeFallback(header); + const parsed = parseExecuteModeFallbackFor3808(section); + assert.equal(parsed.instructsStop, true, + `${skillName}: fallback must include stop/halt/wait instruction. Section was:\n${section}`); + }); + + test(`${skillName}: fallback does NOT contain silent-default anti-pattern`, () => { + const header = getCodexSkillAdapterHeader(skillName); + const section = extractExecuteModeFallback(header); + const parsed = parseExecuteModeFallbackFor3808(section); + assert.equal(parsed.silentlyPicksDefaults, false, + `${skillName}: regression — fallback must NOT instruct the agent to pick defaults autonomously (#3018 / #3808). Section was:\n${section}`); + }); + } + + test('typed semantic-record snapshot for gsd-plan-phase — full contract', () => { + const section = extractExecuteModeFallback(getCodexSkillAdapterHeader('gsd-plan-phase')); + const parsed = parseExecuteModeFallbackFor3808(section); + assert.deepStrictEqual( + { + ok: parsed.ok, + activatesTextMode: parsed.activatesTextMode, + instructsStop: parsed.instructsStop, + presentsPlainText: parsed.presentsPlainText, + silentlyPicksDefaults: parsed.silentlyPicksDefaults, + }, + { + ok: true, + activatesTextMode: true, + instructsStop: true, + presentsPlainText: true, + silentlyPicksDefaults: false, + }, + `gsd-plan-phase: full TEXT_MODE fallback contract violated (#3808). Section was:\n${section}`, + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-570-codex-leak-scanner.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-570-codex-leak-scanner (consolidation epic #1969 B1 #1970)", () => { +// allow-test-rule: source-text-is-the-product (see #570) +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +/** + * Regression tests for issue #570 — three related sub-bugs in the Codex leak + * scanner and supporting infrastructure. + * + * SUB-BUG A: scanForLeakedPaths recursively scans the entire targetDir, + * including pre-existing unrelated files that contain ~/.claude references. + * Fix: scan only files listed in gsd-file-manifest.json. + * + * SUB-BUG B: convertClaudeToCodexMarkdown replaces "~/.claude/" (with trailing + * slash) but NOT bare "~/.claude" (no slash). The scanner regex + * /(?:~|\$HOME)\/\.claude\b/ matches without trailing slash. + * Fix: add bare word-boundary replacement. + * + * SUB-BUG C: writeManifest checks file.endsWith('.md') for the agents/ + * directory. Codex installs .toml agent files, so they are invisible to the + * manifest and thus to any manifest-based scan fix. + * Fix: also check .toml. + */ + +const { describe, test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { runNode } = require('./helpers/process-seam.cjs'); +const { throwIfFailed } = require('./helpers/git-fixture.cjs'); + +const { + install, + convertClaudeCommandToCodexSkill, +} = require('../bin/install.js'); +const { createTempDir, cleanup, captureConsole } = require('./helpers.cjs'); + +const HOOKS_DIST = path.join(__dirname, '..', 'hooks', 'dist'); +const BUILD_HOOKS_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); +// scripts/build-hooks.js copies pre-built hook files into hooks/dist and +// syntax-checks them with vm — it does not compile/bundle anything. See +// tests/helpers/timeouts.cjs for the class-norm justification. +const { BUILD_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); + +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 `/.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; + } +} + +describe('#570 — Codex leak scanner sub-bugs', { concurrency: false }, () => { + let tmpRoot; + let codexHome; + + beforeEach(() => { + if (!fs.existsSync(HOOKS_DIST) || fs.readdirSync(HOOKS_DIST).length === 0) { + throwIfFailed( + runNode([BUILD_HOOKS_SCRIPT], { timeoutMs: BUILD_TIMEOUT_MS }), + `node ${BUILD_HOOKS_SCRIPT}`, + ); + } + tmpRoot = createTempDir('gsd-570-'); + codexHome = path.join(tmpRoot, '.codex'); + fs.mkdirSync(codexHome, { recursive: true }); + }); + + afterEach(() => { + cleanup(tmpRoot); + }); + + // SUB-BUG B + test('convertClaudeToCodexMarkdown replaces bare ~/.claude (no trailing slash)', () => { + // convertClaudeToCodexMarkdown is not exported directly; exercise it via + // convertClaudeCommandToCodexSkill which calls it internally. + const input = 'configDir = ~/.claude\npath = ~/.claude/hooks/\ndir = $HOME/.claude'; + const out = convertClaudeCommandToCodexSkill(input, 'gsd-test'); + + assert.ok( + !/(?:~|\$HOME)\/\.claude\b/.test(out), + `Expected no leaked ~/.claude reference after conversion, got:\n${out}`, + ); + }); + + // SUB-BUG C + test('writeManifest includes .toml agent files for Codex', () => { + withCodexHome(codexHome, () => install(true, 'codex')); + + const agentsDir = path.join(codexHome, 'agents'); + // Not the shared listAgentFiles() helper: this reads the INSTALLED Codex + // dest dir and filters .toml (not source .md), so its semantics differ. + // Confirm that Codex actually wrote .toml agent files — if none exist the + // test is vacuous and we should fail loudly. + const tomlFiles = fs.existsSync(agentsDir) + ? fs.readdirSync(agentsDir).filter((f) => f.startsWith('gsd-') && f.endsWith('.toml')) + : []; + assert.ok( + tomlFiles.length > 0, + `Precondition: Codex install must write at least one gsd-*.toml in agents/; found none in ${agentsDir}`, + ); + + const manifestPath = path.join(codexHome, 'gsd-file-manifest.json'); + assert.ok( + fs.existsSync(manifestPath), + `gsd-file-manifest.json must exist after install; not found at ${manifestPath}`, + ); + + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + const manifestKeys = Object.keys(manifest.files || {}); + + const tomlManifestKeys = manifestKeys.filter( + (k) => k.startsWith('agents/gsd-') && k.endsWith('.toml'), + ); + assert.ok( + tomlManifestKeys.length > 0, + `Expected at least one 'agents/gsd-*.toml' key in manifest.files, but found none.\n` + + `agents/ toml files on disk: ${tomlFiles.join(', ')}\n` + + `All manifest keys (agents/): ${manifestKeys.filter((k) => k.startsWith('agents/')).join(', ')}`, + ); + }); + + // SUB-BUG A + test('scanForLeakedPaths does not warn for pre-existing unrelated files in ~/.codex', () => { + // Write a pre-existing file with ~/.claude references BEFORE install. + const memoriesDir = path.join(codexHome, 'memories'); + fs.mkdirSync(memoriesDir, { recursive: true }); + const preExistingFile = path.join(memoriesDir, 'raw_memories.md'); + fs.writeFileSync( + preExistingFile, + '# Old memories\nI used to work in ~/.claude and $HOME/.claude regularly.\n', + ); + + let captured; + withCodexHome(codexHome, () => { + captured = captureConsole(() => install(true, 'codex')); + }); + + const combinedOutput = captured.stderr; + + assert.ok( + !combinedOutput.includes('memories/raw_memories.md'), + `scanForLeakedPaths must not warn about pre-existing unrelated file memories/raw_memories.md.\n` + + `Actual warnings:\n${combinedOutput}`, + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-704-codex-launcher-path-corruption.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-704-codex-launcher-path-corruption (consolidation epic #1969 B1 #1970)", () => { +// allow-test-rule: source-text-is-the-product (see #704) +'use strict'; + +/** + * Regression test for issue #704: + * "v1.3.1 global install ships literal $gsd-core launcher paths in workflows" + * + * ROOT CAUSE: `convertSlashCommandsToCodexSkillMentions` had a regex + * /(? { + test('convertClaudeCommandToCodexSkill does not corrupt ${VAR}/gsd-core/ or $(cmd)/gsd-* paths', () => { + // Minimal fixture with the launcher snippet and command-substitution patterns + // that were being corrupted (#704). + const input = [ + '---', + 'description: Test skill', + '---', + '', + '```bash', + '_GSD_SHIM_NAME="gsd-tools.cjs"', + '_GSD_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"', + 'GSD_TOOLS="${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"', + 'if [ -f "$GSD_TOOLS" ]; then', + ' gsd_run() { node "$GSD_TOOLS" "$@"; }', + 'elif [ -f "${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then', + ' GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"', + ' gsd_run() { node "$GSD_TOOLS" "$@"; }', + 'elif [ -f "$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then', + ' GSD_TOOLS="$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"', + ' gsd_run() { node "$GSD_TOOLS" "$@"; }', + 'fi', + '# Command-substitution path form (reapply-patches pattern)', + 'candidate="$(expand_home "$KILO_CONFIG_DIR")/gsd-local-patches"', + '```', + ].join('\n'); + + const output = convertClaudeCommandToCodexSkill(input, 'gsd-test-704'); + + // Shell-context corruption patterns from issue #704: + // - `}$gsd-*` from shell variable expressions `${VAR}/gsd-*` + // - `)$gsd-*` from command-substitution paths `$(cmd)/gsd-*` + const shellCorruptionPatterns = [ + { pattern: '}' + BAD_TOKEN, description: 'shell-variable }$gsd-core' }, + { pattern: ')$gsd-local', description: 'command-substitution )$gsd-local-patches' }, + ]; + for (const { pattern, description } of shellCorruptionPatterns) { + assert.ok( + !output.includes(pattern), + `Codex skill conversion must not produce "${pattern}" (${description}). ` + + `Offending fragment: ${ + output.includes(pattern) + ? output.substring(output.indexOf(pattern) - 50, output.indexOf(pattern) + 80) + : '(not found)' + }`, + ); + } + + // The correct path forms must be preserved — the canonical launcher path + // (RUNTIME_ROOT_PATH) must survive Codex conversion intact. + assert.ok( + output.includes(RUNTIME_ROOT_PATH), + `Expected canonical launcher path "${RUNTIME_ROOT_PATH}" to appear in the converted output. ` + + `Got:\n${output.substring(0, 500)}`, + ); + assert.ok( + output.includes(')/gsd-local-patches'), + `Expected ")/gsd-local-patches" to appear in the converted output. ` + + `Got:\n${output.substring(0, 500)}`, + ); + }); + + test('convertClaudeCommandToCodexSkill preserves all shell path forms (}, ) closers)', () => { + // All these paths appear after a shell-closing character (} or )) and must + // NOT be converted to $gsd-* by the Codex slash-command converter. + const shellPaths = [ + // Shell variable expression forms (} closer) + { path: '"${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"', corruptedForm: '}$gsd-core' }, + { path: '"${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"', corruptedForm: '}$gsd-core' }, + { path: '"$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"', corruptedForm: '}$gsd-core' }, + // Command-substitution forms () closer) — reapply-patches pattern + { path: 'candidate="$(expand_home "$KILO_CONFIG_DIR")/gsd-local-patches"', corruptedForm: ')$gsd-local' }, + { path: 'candidate="$(dirname "$(expand_home "$OPENCODE_CONFIG")")/gsd-local-patches"', corruptedForm: ')$gsd-local' }, + ]; + + for (const { path: p, corruptedForm } of shellPaths) { + const input = `---\ndescription: Test\n---\n\n\`\`\`bash\n${p}\n\`\`\``; + const output = convertClaudeCommandToCodexSkill(input, 'gsd-test-704-paths'); + assert.ok( + !output.includes(corruptedForm), + `Path "${p}" was corrupted to contain "${corruptedForm}" after Codex conversion.\n` + + `Got:\n${output}`, + ); + } + }); + + test('convertClaudeCommandToCodexSkill still converts legitimate /gsd- slash mentions', () => { + // Slash-command mentions (not preceded by }) should still be converted + const input = [ + '---', + 'description: Test', + '---', + '', + 'Use /gsd-discuss-phase to start a discussion.', + 'Or use /gsd-plan-phase for planning.', + 'Also: /gsd:capture --backlog adds items.', + ].join('\n'); + + const output = convertClaudeCommandToCodexSkill(input, 'gsd-test-704-cmds'); + + assert.ok( + output.includes('$gsd-discuss-phase'), + 'Expected /gsd-discuss-phase to be converted to $gsd-discuss-phase', + ); + assert.ok( + output.includes('$gsd-plan-phase'), + 'Expected /gsd-plan-phase to be converted to $gsd-plan-phase', + ); + assert.ok( + output.includes('$gsd-capture'), + 'Expected /gsd:capture to be converted to $gsd-capture', + ); + }); + + test('actual shipped workflow files: shell-variable launcher paths contain no $gsd-core', () => { + // Walk gsd-core/workflows/ and assert that no file produces $gsd-core + // inside a shell variable expansion context after Codex conversion. + // + // NOTE: The backtick-wrapped prose-path case (`/gsd-core/workflows/update.md`) + // was a pre-existing gap with the #704 lookbehind fix and is now addressed by + // the positive-boundary regex introduced in #712. That case is covered by the + // "#712" describe block below. + // + // We probe for the specific shell-context pattern from the issue report: + // BAD: ${_GSD_RUNTIME_ROOT}$gsd-core/bin/ + // GOOD: ${_GSD_RUNTIME_ROOT}/gsd-core/bin/ + const workflowsDir = path.join(__dirname, '..', 'gsd-core', 'workflows'); + if (!fs.existsSync(workflowsDir)) { + // If the directory doesn't exist, skip gracefully (non-standard layout) + return; + } + + const files = fs.readdirSync(workflowsDir) + .filter((f) => f.endsWith('.md')) + .map((f) => path.join(workflowsDir, f)); + + assert.ok(files.length > 0, 'Expected at least one workflow .md file'); + + // Shell-context corruption patterns from issue #704: + // - `}$gsd-*`: closing brace from `${VAR}/gsd-*` shell variable expressions + // - `)$gsd-*`: closing paren from `$(cmd)/gsd-*` command substitutions + const SHELL_CORRUPTION_RE = /[})](\$gsd-[a-z])/; + + const offending = []; + for (const file of files) { + const content = fs.readFileSync(file, 'utf8'); + const skillName = `gsd-${path.basename(file, '.md')}`; + const converted = convertClaudeCommandToCodexSkill(content, skillName); + const match = converted.match(SHELL_CORRUPTION_RE); + if (match) { + const idx = converted.indexOf(match[0]); + offending.push({ + file: path.relative(workflowsDir, file), + context: converted.substring(Math.max(0, idx - 40), idx + 80), + }); + } + } + + assert.deepStrictEqual( + offending, + [], + `Found shell-context path corruption ([})]$gsd-*) in Codex-converted workflow files (#704):\n` + + offending.map((o) => ` ${o.file}: ...${o.context}...`).join('\n'), + ); + }); + + test('commands/gsd/*.md: shell-variable launcher paths contain no $gsd-core', () => { + // Walk commands/gsd/ and assert that no command file produces the shell-context + // }$gsd-core corruption — since commands also go through + // convertClaudeCommandToCodexSkill when installed globally for Codex. + const commandsDir = path.join(__dirname, '..', 'commands', 'gsd'); + if (!fs.existsSync(commandsDir)) return; + + const files = fs.readdirSync(commandsDir) + .filter((f) => f.endsWith('.md')) + .map((f) => path.join(commandsDir, f)); + + assert.ok(files.length > 0, 'Expected at least one command .md file'); + + const SHELL_CORRUPTION_RE = /[})](\$gsd-[a-z])/; + + const offending = []; + for (const file of files) { + const content = fs.readFileSync(file, 'utf8'); + const skillName = `gsd-${path.basename(file, '.md')}`; + const converted = convertClaudeCommandToCodexSkill(content, skillName); + const match = converted.match(SHELL_CORRUPTION_RE); + if (match) { + const idx = converted.indexOf(match[0]); + offending.push({ + file: path.relative(commandsDir, file), + context: converted.substring(Math.max(0, idx - 40), idx + 80), + }); + } + } + + assert.deepStrictEqual( + offending, + [], + `Found shell-context path corruption ([})]$gsd-*) in Codex-converted command files (#704):\n` + + offending.map((o) => ` ${o.file}: ...${o.context}...`).join('\n'), + ); + }); +}); + +describe('#712: positive-boundary slash-command conversion', () => { + // Tests call convertSlashCommandsToCodexSkillMentions directly so the regex + // is exercised in isolation — no frontmatter wrapping, no ADAPTER_CLOSE + // stripping, no .claude→.codex rewrite masking the result. + + // ── MUST-NOT-CONVERT (negative) cases ───────────────────────────────────── + // These inputs must be returned UNCHANGED — no $gsd-* substitution. + + test('backtick-wrapped path: `/gsd-core/workflows/update.md` is NOT converted (THE new fix)', () => { + const input = 'See `/gsd-core/workflows/update.md` for details.'; + const result = convertSlashCommandsToCodexSkillMentions(input); + assert.strictEqual( + result, + input, + `Expected backtick-wrapped path to be unchanged. Got: ${result}`, + ); + }); + + test('backtick-wrapped path deeper: `/gsd-pi/bin/foo.cjs` is NOT converted', () => { + const input = 'Run `/gsd-pi/bin/foo.cjs` directly.'; + const result = convertSlashCommandsToCodexSkillMentions(input); + assert.strictEqual( + result, + input, + `Expected deep backtick-wrapped path to be unchanged. Got: ${result}`, + ); + }); + + test('shell var expansion: ${_GSD_RUNTIME_ROOT}/gsd-core/bin/x is NOT converted (regression guard)', () => { + const input = 'PATH="${_GSD_RUNTIME_ROOT}/gsd-core/bin/x"'; + const result = convertSlashCommandsToCodexSkillMentions(input); + assert.ok( + !result.includes('$gsd-core'), + `Expected no $gsd-core substitution in shell var path. Got: ${result}`, + ); + assert.ok( + result.includes('/gsd-core/bin/x'), + `Expected original path to be preserved. Got: ${result}`, + ); + }); + + test('command substitution: $(expand_home ~/.claude)/gsd-local-patches is NOT converted (regression guard)', () => { + const input = 'candidate="$(expand_home ~/.claude)/gsd-local-patches"'; + const result = convertSlashCommandsToCodexSkillMentions(input); + assert.ok( + !result.includes(')$gsd-local'), + `Expected no )$gsd-local substitution. Got: ${result}`, + ); + assert.ok( + result.includes(')/gsd-local-patches'), + `Expected original path to be preserved. Got: ${result}`, + ); + }); + + test('plain path segment: bin/gsd-tools.cjs is NOT converted', () => { + const input = 'node bin/gsd-tools.cjs --help'; + const result = convertSlashCommandsToCodexSkillMentions(input); + assert.strictEqual( + result, + input, + `Expected plain path segment to be unchanged. Got: ${result}`, + ); + }); + + test('plain path segment: .claude/gsd-core/agents — /gsd-core portion is NOT slash-command converted', () => { + // Tests the regex in isolation: the .claude→.codex path rewrite that happens + // inside convertClaudeToCodexMarkdown does NOT run here. We assert directly + // that the slash-command regex leaves /gsd-core after the slash intact — + // i.e. the `e` in `/gsd-core` is NOT treated as a command boundary. + const input = 'Look in .claude/gsd-core/agents for the agent files.'; + const result = convertSlashCommandsToCodexSkillMentions(input); + assert.ok( + !result.includes('$gsd-core'), + `Expected no $gsd-core substitution in .claude/gsd-core path. Got: ${result}`, + ); + assert.ok( + result.includes('/gsd-core/agents'), + `Expected /gsd-core/agents to remain as a path segment. Got: ${result}`, + ); + }); + + // ── MUST-CONVERT (positive) cases ───────────────────────────────────────── + // These inputs contain legitimate /gsd- mentions that MUST be converted. + + test('space-preceded prose: Use /gsd-discuss-phase to start. → $gsd-discuss-phase', () => { + const input = 'Use /gsd-discuss-phase to start.'; + const result = convertSlashCommandsToCodexSkillMentions(input); + assert.ok( + result.includes('$gsd-discuss-phase'), + `Expected /gsd-discuss-phase to be converted. Got: ${result}`, + ); + assert.ok( + !result.includes('/gsd-discuss-phase'), + `Expected original /gsd-discuss-phase to be replaced. Got: ${result}`, + ); + }); + + test('backtick-WRAPPED MENTION (single segment): Run `/gsd-execute-phase` now → `$gsd-execute-phase`', () => { + // A backtick-wrapped COMMAND (single segment, no path continuation) MUST + // still be converted — this guards against a naive whitespace-only fix. + const input = 'Run `/gsd-execute-phase` now.'; + const result = convertSlashCommandsToCodexSkillMentions(input); + assert.ok( + result.includes('`$gsd-execute-phase`'), + `Expected backtick-wrapped command to be converted to \`$gsd-execute-phase\`. Got: ${result}`, + ); + assert.ok( + !result.includes('`/gsd-execute-phase`'), + `Expected original \`/gsd-execute-phase\` to be replaced. Got: ${result}`, + ); + }); + + test('parenthetical/backtick list like CONTEXT.md:59: (`/gsd-plan-phase`, `/gsd-progress`) → converted', () => { + const input = 'Available commands: (`/gsd-plan-phase`, `/gsd-progress`) — pick one.'; + const result = convertSlashCommandsToCodexSkillMentions(input); + assert.ok( + result.includes('`$gsd-plan-phase`'), + `Expected /gsd-plan-phase to be converted. Got: ${result}`, + ); + assert.ok( + result.includes('`$gsd-progress`'), + `Expected /gsd-progress to be converted. Got: ${result}`, + ); + }); + + test('start-of-string: /gsd-manager runs → $gsd-manager runs (exercises the ^ branch of lookbehind)', () => { + // This case is IMPOSSIBLE to test through the frontmatter-wrapping pipeline + // (the body always has preceding chars). Direct call exercises the ^ branch. + const input = '/gsd-manager runs the pipeline.'; + const result = convertSlashCommandsToCodexSkillMentions(input); + assert.ok( + result.includes('$gsd-manager'), + `Expected /gsd-manager to be converted. Got: ${result}`, + ); + assert.ok( + !result.includes('/gsd-manager'), + `Expected original /gsd-manager to be replaced. Got: ${result}`, + ); + }); + + test('double-quote wrapped: "/gsd-resume" → "$gsd-resume"', () => { + const input = 'Call "/gsd-resume" to continue.'; + const result = convertSlashCommandsToCodexSkillMentions(input); + assert.ok( + result.includes('"$gsd-resume"'), + `Expected "/gsd-resume" to be converted to "$gsd-resume". Got: ${result}`, + ); + assert.ok( + !result.includes('"/gsd-resume"'), + `Expected original "/gsd-resume" to be replaced. Got: ${result}`, + ); + }); + + // ── End-to-end: headline #712 bug through the real install pipeline ──────── + + test('end-to-end: backtick-wrapped path `/gsd-core/workflows/update.md` survives full Codex install pipeline', () => { + // Uses convertClaudeCommandToCodexSkill (same pattern as #704 tests above) + // to prove the real install path does not corrupt prose references to repo paths. + const input = [ + '---', + 'description: Test', + '---', + '', + 'See `/gsd-core/workflows/update.md` for the update workflow.', + ].join('\n'); + + const output = convertClaudeCommandToCodexSkill(input, 'gsd-test-712-e2e'); + + assert.ok( + !output.includes('$gsd-core'), + `Expected no $gsd-core in converted output. Got:\n${output}`, + ); + assert.ok( + output.includes('/gsd-core/workflows/update.md'), + `Expected backtick-wrapped path to survive conversion. Got:\n${output}`, + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-851-codex-quick-adapter-agent-type-fallback.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-851-codex-quick-adapter-agent-type-fallback (consolidation epic #1969 B1 #1970)", () => { +// allow-test-rule: source-text-is-the-product (see #851) +// Tests assert on text in bin/install.js (Codex adapter header prose) — +// the adapter text IS the product loaded by Codex agents at runtime. + +'use strict'; + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const INSTALL_JS = path.join(__dirname, '..', 'bin', 'install.js'); +const src = fs.readFileSync(INSTALL_JS, 'utf8'); + +// Helper: extract Section C from the raw source text. +// Anchors on the heading and ends at . +function getSectionC() { + // allow-test-rule: source-text-is-the-product (see #851) + const headingIdx = src.indexOf('## C. Task() → spawn_agent Mapping'); // allow-test-rule: source-text-is-the-product (see #851) + assert.ok(headingIdx >= 0, 'Section C heading must exist in bin/install.js'); + const closeTag = src.indexOf('', headingIdx); // allow-test-rule: source-text-is-the-product (see #851) + assert.ok(closeTag >= 0, 'Section C must be followed by '); + return src.slice(headingIdx, closeTag); +} + +describe('bug #851: Codex adapter documents multi_agent_v1 schema limitation and fallback', () => { + + // (a) Schema-detection step: the adapter must require the agent to inspect + // spawn_agent's parameter schema BEFORE deciding how to dispatch. + test('(a) schema-detection: adapter requires inspecting spawn_agent schema before dispatching', () => { + const sectionC = getSectionC(); + + // Must name BOTH schema variants so the agent knows what to look for + assert.ok( + sectionC.includes('multi_agent_v1'), + 'Section C must name the multi_agent_v1 schema to identify the limited form', + ); + assert.ok( + sectionC.includes('multi_agent_v2') || sectionC.includes('agent_type-capable'), + 'Section C must name the typed schema (multi_agent_v2 or agent_type-capable) as the capable form', + ); + + // Must instruct schema inspection before spawning + assert.ok( + sectionC.includes('tool_search') || sectionC.includes('inspect') || sectionC.includes('schema'), + 'Section C must instruct the agent to inspect the spawn_agent schema (via tool_search or similar)', + ); + + // All three requirements together (AND): + assert.ok( + sectionC.includes('multi_agent_v1') && + (sectionC.includes('multi_agent_v2') || sectionC.includes('agent_type-capable')) && + (sectionC.includes('tool_search') || sectionC.includes('inspect') || sectionC.includes('schema')), + 'Section C must require schema-detection: name both schema variants AND instruct inspection before spawning', + ); + }); + + // (b) Active-config-root resolution: the TOML path must describe how to + // resolve the config root (honoring $CODEX_HOME / --config-dir / --local), + // not imply a single fixed path. + test('(b) active-config-root: fallback TOML path resolves the active Codex config root', () => { + const sectionC = getSectionC(); + + // Must mention the agents/.toml relative path + assert.ok( + sectionC.includes('agents/.toml'), + 'Section C must reference agents/.toml for the TOML extraction step', + ); + + // Must describe dynamic config-root resolution (at least two of the three + // override mechanisms, plus the word "config" to anchor context) + const mentionsCodexHome = sectionC.includes('$CODEX_HOME') || sectionC.includes('CODEX_HOME'); + const mentionsConfigDir = sectionC.includes('--config-dir') || sectionC.includes('config-dir'); + const mentionsLocal = sectionC.includes('--local') || sectionC.includes('.codex') || sectionC.includes('local'); + const mentionsConfigRoot = sectionC.includes('config root') || sectionC.includes('config.toml') || sectionC.includes('config directory'); + + assert.ok( + mentionsCodexHome, + 'Section C fallback must mention $CODEX_HOME for config-root resolution', + ); + assert.ok( + mentionsConfigDir, + 'Section C fallback must mention --config-dir for config-root resolution', + ); + assert.ok( + mentionsLocal, + 'Section C fallback must mention --local / .codex for config-root resolution', + ); + assert.ok( + mentionsConfigRoot, + 'Section C fallback must describe the concept of an active config root (config.toml or config root/directory)', + ); + + // AND: all four required elements together + assert.ok( + mentionsCodexHome && mentionsConfigDir && mentionsLocal && mentionsConfigRoot, + 'Section C fallback must describe active-config-root resolution: $CODEX_HOME + --config-dir + --local + config-root concept (AND logic)', + ); + + // Must NOT contain the literal ~/.codex/ (would be rewritten by _applyRuntimeRewrites + // and cause bug-3582 to diverge) + assert.ok( + !sectionC.includes('~/.codex/'), + 'Section C must NOT contain the literal ~/.codex/ substring (breaks bug-3582 materialization test)', + ); + }); + + // (c) "NOT equivalent" label: the workaround must be explicitly labeled as + // not equivalent to typed gsd-planner/gsd-executor execution. + test('(c) not-equivalent label: generic-agent workaround is labeled as NOT equivalent to typed dispatch', () => { + const sectionC = getSectionC(); + + // Must name at least one typed agent + const namesTypedAgent = + sectionC.includes('gsd-planner') || + sectionC.includes('gsd-executor') || + sectionC.includes('typed GSD agent') || + sectionC.includes('typed gsd-'); + + // Must contain explicit "not equivalent" / "NOT equivalent" / negation language + const hasNotEquivalent = + sectionC.toLowerCase().includes('not equivalent') || + sectionC.includes('NOT equivalent') || + sectionC.includes('is NOT possible'); + + // Must name the workaround as a workaround, not a first-class path + const hasWorkaroundLabel = + sectionC.includes('workaround') || + sectionC.includes('fallback'); + + assert.ok( + namesTypedAgent, + 'Section C must name at least one typed GSD agent (gsd-planner, gsd-executor, or "typed GSD agent")', + ); + assert.ok( + hasNotEquivalent, + 'Section C must contain explicit "not equivalent" / "NOT equivalent" language for the generic-agent path', + ); + assert.ok( + hasWorkaroundLabel, + 'Section C must label the generic-agent path as a workaround or fallback', + ); + + // AND: all three together + assert.ok( + namesTypedAgent && hasNotEquivalent && hasWorkaroundLabel, + 'Section C must AND: name a typed agent + label it NOT equivalent + call the generic path a workaround/fallback', + ); + }); + + // (d) Fail-closed rule: when typed dispatch is mandatory, the adapter must + // instruct the agent to fail closed and report the limitation, not silently degrade. + test('(d) fail-closed: adapter requires failing closed when typed dispatch is mandatory', () => { + const sectionC = getSectionC(); + + const hasFailClosed = + sectionC.includes('fail closed') || + sectionC.includes('fail-closed') || + sectionC.includes('fail_closed'); + + const hasReportLimitation = + sectionC.includes('schema limitation') || + sectionC.includes('report') || + sectionC.includes('not silently') || + sectionC.includes('silently degrading') || + sectionC.includes('silently'); + + const hasMandatoryContext = + sectionC.includes('mandatory') || + sectionC.includes('required') || + sectionC.includes('worktree isolation') || + sectionC.includes('isolation'); + + assert.ok( + hasFailClosed, + 'Section C must instruct fail-closed behavior (the phrase "fail closed" or equivalent)', + ); + assert.ok( + hasReportLimitation, + 'Section C must instruct reporting the schema limitation rather than silently degrading', + ); + assert.ok( + hasMandatoryContext, + 'Section C must identify a context where typed dispatch is mandatory (e.g. worktree isolation)', + ); + + // AND: all three together + assert.ok( + hasFailClosed && hasReportLimitation && hasMandatoryContext, + 'Section C must AND: instruct fail-closed + report limitation + identify mandatory-typed-dispatch contexts', + ); + }); + + // Regression guard: typed mapping for capable schema must still be present. + test('adapter still documents typed agent_type spawn for sessions that support it', () => { + const sectionC = getSectionC(); + + assert.ok( + sectionC.includes('agent_type-capable') || sectionC.includes('multi_agent_v2'), + 'Section C must still document the typed schema (agent_type-capable / multi_agent_v2)', + ); + assert.ok( + sectionC.includes('spawn_agent(agent_type=') || sectionC.includes('agent_type="X"'), + 'Section C must still show a typed spawn_agent(agent_type=...) example for capable sessions', + ); + }); + + // Regression guard: deferred tool discovery must remain (bug-279 contract). + test('adapter deferred tool discovery instruction is preserved', () => { + // The pre-existing bug-279 contract must remain intact + // allow-test-rule: source-text-is-the-product (see #851) + assert.ok( + src.includes('deferred') && src.includes('tool_search') && src.includes('spawn_agent'), // allow-test-rule: source-text-is-the-product (see #851) + 'Adapter must still instruct deferred tool discovery via tool_search before deciding to run inline', + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/enh-772-codex-hook-events.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:enh-772-codex-hook-events (consolidation epic #1969 B1 #1970)", () => { +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +/** + * Enhancement #772: Adopt new stable Codex hook events + commandWindows for + * Windows parity. + * + * Codex CLI (rust-v0.137.0) stabilised the full hook-event set. This suite + * asserts that a Codex install: + * + * (a) Registers the 3 new high-value hook events in hooks.json: + * - SubagentStart — inject context / GSD_AGENT_NAME awareness at subagent open + * - Stop — post-session context headroom tracking + * - PostToolUse — mirror the Claude Code PostToolUse context monitor + * + * (b) Emits `commandWindows` in the SessionStart hooks.json entry so that + * Windows users get the .cmd shim path and non-Windows users get the POSIX + * node runner command. Both fields are present in the same entry; Codex picks + * the right one per its HookHandlerConfig schema + * (codex-rs/config/src/hook_config.rs: commandWindows / command_windows alias). + * + * Note: UserPromptSubmit is NOT wired (same rationale as Qwen #788 — the + * gsd-prompt-guard handler exits unless tool_name is Write|Edit, so it would be + * a silent no-op for the UserPromptSubmit payload shape). + * + * Test strategy: + * - Test new event registration via ensureCodexHooksJsonEvent() directly + * (mirrors the #3426 pattern of testing ensureCodexHooksJsonSessionStart + * directly with a stub hook file — avoids full install() migration dance). + * - Test commandWindows via ensureCodexHooksJsonSessionStart() directly. + * - IR-first discipline: assert on the structured result, not rendered text. + * + * Verified hook event schema: + * https://github.com/openai/codex/blob/main/codex-rs/protocol/src/protocol.rs + * https://github.com/openai/codex/blob/main/codex/codex-rs/config/src/hook_config.rs + */ + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { + ensureCodexHooksJsonSessionStart, + ensureCodexHooksJsonEvent, + removeCodexHooksJsonEvent, + reconcileCodexHooksJsonEvent, +} = require('../gsd-core/bin/lib/runtime-hooks-surface.cjs'); +const { createTempDir, cleanup } = require('./helpers.cjs'); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** + * Extract all hook handler entries (full objects with type/command/etc.) for + * `eventName` from a hooks.json object (flat or nested-hooks shape). + */ +function hooksJsonHandlersForEvent(hooksJson, eventName) { + if (!hooksJson || typeof hooksJson !== 'object') return []; + const table = + hooksJson.hooks && typeof hooksJson.hooks === 'object' && !Array.isArray(hooksJson.hooks) + ? hooksJson.hooks + : hooksJson; + if (!Array.isArray(table[eventName])) return []; + return table[eventName].flatMap(entry => + Array.isArray(entry && entry.hooks) ? entry.hooks : [] + ); +} + +function readHooksJson(targetDir) { + const p = path.join(targetDir, 'hooks.json'); + if (!fs.existsSync(p)) return null; + return JSON.parse(fs.readFileSync(p, 'utf8')); +} + +function stubHookFile(targetDir, hookName) { + const hooksDest = path.join(targetDir, 'hooks'); + fs.mkdirSync(hooksDest, { recursive: true }); + const dest = path.join(hooksDest, hookName); + if (!fs.existsSync(dest)) { + fs.writeFileSync(dest, '#!/usr/bin/env node\n// stub\n'); + try { fs.chmodSync(dest, 0o755); } catch { /* Windows */ } + } +} + +// ─── Suite 1: ensureCodexHooksJsonEvent export surface ─────────────────────── + +describe('enh-772: export surface — new functions are exported', () => { + test('ensureCodexHooksJsonEvent is a function', () => { + assert.strictEqual(typeof ensureCodexHooksJsonEvent, 'function', + 'ensureCodexHooksJsonEvent must be exported from runtime-hooks-surface.cjs'); + }); + + test('removeCodexHooksJsonEvent is a function', () => { + assert.strictEqual(typeof removeCodexHooksJsonEvent, 'function', + 'removeCodexHooksJsonEvent must be exported from runtime-hooks-surface.cjs'); + }); + + test('reconcileCodexHooksJsonEvent is a function', () => { + assert.strictEqual(typeof reconcileCodexHooksJsonEvent, 'function', + 'reconcileCodexHooksJsonEvent must be exported from runtime-hooks-surface.cjs'); + }); +}); + +// ─── Suite 2: ensureCodexHooksJsonEvent registers new events ───────────────── + +describe('enh-772: ensureCodexHooksJsonEvent registers SubagentStart, Stop, PostToolUse', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempDir('gsd-772-events-'); + stubHookFile(tmpDir, 'gsd-context-monitor.js'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + for (const eventName of ['SubagentStart', 'Stop', 'PostToolUse']) { + test(`${eventName}: ensureCodexHooksJsonEvent writes hooks.json`, () => { + const fakeRunner = '"/usr/local/bin/node"'; + const result = ensureCodexHooksJsonEvent(tmpDir, eventName, { + absoluteRunner: fakeRunner, + platform: 'linux', + }); + assert.ok(result && result.path, `result must have path for ${eventName}`); + assert.ok(result.wrote || result.changed, + `ensureCodexHooksJsonEvent must write or change hooks.json for ${eventName}`); + assert.ok(fs.existsSync(path.join(tmpDir, 'hooks.json')), + `hooks.json must exist after registering ${eventName}`); + }); + + test(`${eventName}: hooks.json contains the event entry`, () => { + const fakeRunner = '"/usr/local/bin/node"'; + ensureCodexHooksJsonEvent(tmpDir, eventName, { + absoluteRunner: fakeRunner, + platform: 'linux', + }); + const hooksJson = readHooksJson(tmpDir); + const handlers = hooksJsonHandlersForEvent(hooksJson, eventName); + assert.ok(handlers.length > 0, + `Expected ${eventName} entry in hooks.json; got: ${JSON.stringify(hooksJson)}`); + }); + + test(`${eventName}: hook entry uses gsd-context-monitor`, () => { + const fakeRunner = '"/usr/local/bin/node"'; + ensureCodexHooksJsonEvent(tmpDir, eventName, { + absoluteRunner: fakeRunner, + platform: 'linux', + }); + const hooksJson = readHooksJson(tmpDir); + const handlers = hooksJsonHandlersForEvent(hooksJson, eventName); + assert.ok( + handlers.some(h => h.command && h.command.includes('gsd-context-monitor')), + `${eventName} hook must use gsd-context-monitor; got: ${JSON.stringify(handlers)}` + ); + }); + + test(`${eventName}: hook entry has type: 'command'`, () => { + const fakeRunner = '"/usr/local/bin/node"'; + ensureCodexHooksJsonEvent(tmpDir, eventName, { + absoluteRunner: fakeRunner, + platform: 'linux', + }); + const hooksJson = readHooksJson(tmpDir); + const handlers = hooksJsonHandlersForEvent(hooksJson, eventName); + const entry = handlers.find(h => h.command && h.command.includes('gsd-context-monitor')); + assert.strictEqual(entry && entry.type, 'command', + `${eventName} hook entry must have type 'command'`); + }); + + test(`${eventName}: hook entry has timeout: 10`, () => { + const fakeRunner = '"/usr/local/bin/node"'; + ensureCodexHooksJsonEvent(tmpDir, eventName, { + absoluteRunner: fakeRunner, + platform: 'linux', + }); + const hooksJson = readHooksJson(tmpDir); + const handlers = hooksJsonHandlersForEvent(hooksJson, eventName); + const entry = handlers.find(h => h.command && h.command.includes('gsd-context-monitor')); + assert.strictEqual(entry && entry.timeout, 10, + `${eventName} hook entry must have timeout 10`); + }); + } + + test('null absoluteRunner returns unchanged result without writing', () => { + const result = ensureCodexHooksJsonEvent(tmpDir, 'SubagentStart', { + absoluteRunner: null, + platform: 'linux', + }); + assert.strictEqual(result.changed, false, + 'null runner must return changed: false'); + assert.ok(!fs.existsSync(path.join(tmpDir, 'hooks.json')), + 'hooks.json must NOT be written when runner is null'); + }); +}); + +// ─── Suite 3: commandWindows parity in SessionStart ────────────────────────── + +describe('enh-772: commandWindows parity — ensureCodexHooksJsonSessionStart emits commandWindows', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempDir('gsd-772-cmdwin-'); + stubHookFile(tmpDir, 'gsd-check-update.js'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + // commandWindows is ONLY emitted on win32 platform (where the .cmd shim is also + // written). On POSIX platforms, commandWindows is omitted to avoid pointing Windows + // Codex at a non-existent .cmd file (the shim is only present after a native Windows + // install that runs buildCodexHookWindowsShimIR and atomicWriteFileSync). + + test('POSIX platform: commandWindows is NOT emitted (shim not written on POSIX)', () => { + const fakeRunner = '"/usr/local/bin/node"'; + const result = ensureCodexHooksJsonSessionStart(tmpDir, { + absoluteRunner: fakeRunner, + platform: 'linux', + }); + assert.ok(result && result.wrote, 'must write hooks.json on linux'); + + const hooksJson = readHooksJson(tmpDir); + const handlers = hooksJsonHandlersForEvent(hooksJson, 'SessionStart'); + assert.ok(handlers.length > 0, `Expected SessionStart handlers; got: ${JSON.stringify(hooksJson)}`); + + const entry = handlers[0]; + assert.ok( + entry.commandWindows === undefined, + `commandWindows must NOT be emitted on POSIX (shim not written); got: ${JSON.stringify(entry)}` + ); + }); + + test('POSIX platform: command references gsd-check-update.js (not .cmd)', () => { + const fakeRunner = '"/usr/local/bin/node"'; + ensureCodexHooksJsonSessionStart(tmpDir, { + absoluteRunner: fakeRunner, + platform: 'linux', + }); + const hooksJson = readHooksJson(tmpDir); + const handlers = hooksJsonHandlersForEvent(hooksJson, 'SessionStart'); + const entry = handlers[0]; + assert.ok( + entry.command && entry.command.includes('gsd-check-update'), + `POSIX command must reference gsd-check-update; got: ${entry.command}` + ); + assert.ok( + !entry.command.endsWith('.cmd') && !entry.command.endsWith('.cmd"'), + `POSIX command must not end with .cmd; got: ${entry.command}` + ); + }); + + test('null absoluteRunner: no commandWindows emitted, no write', () => { + const result = ensureCodexHooksJsonSessionStart(tmpDir, { + absoluteRunner: null, + platform: 'linux', + }); + assert.strictEqual(result.changed, false, 'null runner must return changed: false'); + const hooksJson = readHooksJson(tmpDir); + if (hooksJson) { + const handlers = hooksJsonHandlersForEvent(hooksJson, 'SessionStart'); + for (const h of handlers) { + assert.ok(!h.commandWindows, + `commandWindows must not be present when runner is null; got: ${JSON.stringify(h)}`); + } + } + }); + + test('Windows platform: SessionStart hook is written with commandWindows pointing to .cmd shim', () => { + // On win32, both `command` and `commandWindows` use the .cmd shim path + // (because managedCommand = shimIR.hookCommand = .cmd path, and + // commandWindows = same .cmd path). This ensures Codex picks the .cmd + // on Windows regardless of which field it reads. + const fakeRunner = '"C:/Program Files/nodejs/node.exe"'; + const result = ensureCodexHooksJsonSessionStart(tmpDir, { + absoluteRunner: fakeRunner, + platform: 'win32', + }); + // The shim write and hooks.json write should succeed in the tmp dir. + if (result.wrote) { + const hooksJson = readHooksJson(tmpDir); + const handlers = hooksJsonHandlersForEvent(hooksJson, 'SessionStart'); + assert.ok(handlers.length > 0, + `SessionStart must be registered on Windows path; got: ${JSON.stringify(hooksJson)}`); + const entry = handlers[0]; + assert.ok(typeof entry.commandWindows === 'string', + `commandWindows must be present on Windows path; got: ${JSON.stringify(entry)}`); + // commandWindows should reference the .cmd shim + assert.ok( + entry.commandWindows.includes('gsd-check-update') && entry.commandWindows.includes('.cmd'), + `commandWindows must reference gsd-check-update.cmd on win32; got: ${entry.commandWindows}` + ); + } + }); +}); + +// ─── Suite 4: idempotency ──────────────────────────────────────────────────── + +describe('enh-772: ensureCodexHooksJsonEvent is idempotent', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempDir('gsd-772-idem-'); + stubHookFile(tmpDir, 'gsd-context-monitor.js'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + for (const eventName of ['SubagentStart', 'Stop', 'PostToolUse']) { + test(`${eventName}: calling twice does not duplicate hook entries`, () => { + const fakeRunner = '"/usr/local/bin/node"'; + const opts = { absoluteRunner: fakeRunner, platform: 'linux' }; + + ensureCodexHooksJsonEvent(tmpDir, eventName, opts); + ensureCodexHooksJsonEvent(tmpDir, eventName, opts); + + const hooksJson = readHooksJson(tmpDir); + const handlers = hooksJsonHandlersForEvent(hooksJson, eventName); + assert.strictEqual(handlers.length, 1, + `${eventName} should have exactly 1 hook handler after idempotent re-register; got ${handlers.length}: ${JSON.stringify(handlers)}`); + }); + } +}); + +// ─── Suite 5: removeCodexHooksJsonEvent ────────────────────────────────────── + +describe('enh-772: removeCodexHooksJsonEvent removes managed entries', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempDir('gsd-772-remove-'); + stubHookFile(tmpDir, 'gsd-context-monitor.js'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + for (const eventName of ['SubagentStart', 'Stop', 'PostToolUse']) { + test(`${eventName}: removeCodexHooksJsonEvent removes the managed entry`, () => { + const fakeRunner = '"/usr/local/bin/node"'; + ensureCodexHooksJsonEvent(tmpDir, eventName, { + absoluteRunner: fakeRunner, + platform: 'linux', + }); + + // Verify it was registered + let hooksJson = readHooksJson(tmpDir); + let handlers = hooksJsonHandlersForEvent(hooksJson, eventName); + assert.ok(handlers.length > 0, `${eventName} must be registered before removal`); + + // Remove + const result = removeCodexHooksJsonEvent(tmpDir, eventName); + assert.ok(result.changed || result.wrote, + `removeCodexHooksJsonEvent must change hooks.json for ${eventName}`); + + hooksJson = readHooksJson(tmpDir); + if (hooksJson) { + handlers = hooksJsonHandlersForEvent(hooksJson, eventName); + assert.strictEqual(handlers.length, 0, + `After removal, ${eventName} should have 0 handlers; got: ${JSON.stringify(handlers)}`); + } + }); + } +}); + +// ─── Suite 6: reconcileCodexHooksJsonEvent preserves user entries ───────────── + +describe('enh-772: reconcileCodexHooksJsonEvent preserves user-owned entries', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempDir('gsd-772-preserve-'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('user-owned SubagentStart entry is preserved when GSD entry is registered', () => { + const hooksJsonPath = path.join(tmpDir, 'hooks.json'); + const userEntry = { + hooks: [{ type: 'command', command: 'my-custom-hook.sh' }] + }; + fs.writeFileSync(hooksJsonPath, JSON.stringify({ + SubagentStart: [userEntry] + }, null, 2) + '\n'); + + reconcileCodexHooksJsonEvent(tmpDir, 'SubagentStart', { + managedCommand: '"/usr/local/bin/node" "/home/me/.codex/hooks/gsd-context-monitor.js"', + }); + + const hooksJson = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8')); + const table = hooksJson.hooks || hooksJson; + const entries = Array.isArray(table.SubagentStart) ? table.SubagentStart : []; + // Should have 2 entries: user entry + GSD entry + assert.ok(entries.length >= 2, + `User entry must be preserved; got entries: ${JSON.stringify(entries)}`); + // User entry must still be present + const userEntryStillPresent = entries.some(e => + Array.isArray(e.hooks) && e.hooks.some(h => h.command === 'my-custom-hook.sh') + ); + assert.ok(userEntryStillPresent, + `User entry must survive GSD registration; entries: ${JSON.stringify(entries)}`); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2866-codex-strip-no-trailing-newline.test.cjs — consolidation epic #1969 (B8 #1977) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2866-codex-strip-no-trailing-newline (consolidation epic #1969 B8 #1977)", () => { +/** + * Bug #2866: Codex Installer (RC.7) fails to strip legacy flat hooks if + * trailing newline is missing. + * + * The cleanup regexes in `bin/install.js` matched stale GSD hook blocks + * via `\r?\n` at the end. When a stale block sat at end-of-file without + * a trailing newline (very common — many editors strip them, and the + * legacy installer never wrote one), no shape stripped, the installer + * saw `gsd-check-update` already present, skipped writing the new + * Nested-AoT block, and Codex 0.125+ refused to load with + * "invalid type: map, expected a sequence in `hooks`" + * + * Fix: every shape's terminator is now `(?:\r?\n|$)` so end-of-file + * counts as a valid terminator. The strip logic was lifted into a pure + * helper, `stripStaleGsdHookBlocks(configContent)`, exported from + * `bin/install.js` for direct test coverage. + * + * This test parses `package.json` to require `bin/install.js` + * structurally (not by hardcoded path), then drives each historical + * shape through the helper twice — once with a trailing newline, once + * without — and asserts both are stripped. + */ +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const fs = require('node:fs'); + +const REPO_ROOT = path.join(__dirname, '..'); +const pkg = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, 'package.json'), 'utf-8')); +const installPath = path.resolve(REPO_ROOT, pkg.bin['gsd-core']); +const { stripStaleGsdHookBlocks } = require(installPath); + +/** + * Parse the TOML output line-structurally so assertions check shape, not + * substring presence in raw text. Comments are dropped, table headers are + * recorded, and string-valued keys are captured. Sufficient for the small, + * well-formed TOML produced by these tests. + */ +function parseTomlShape(text) { + const tableHeaders = []; + const keys = new Map(); // dotted path → string value (last-write-wins, fine for these inputs) + let currentTable = ''; + for (const rawLine of text.split('\n')) { + const line = rawLine.replace(/(?:^|\s)#.*$/, '').trim(); + if (!line) continue; + const tableMatch = line.match(/^\[(\[)?([^\]]+)\]?\]$/); + if (tableMatch) { + currentTable = tableMatch[2]; + tableHeaders.push((tableMatch[1] ? '[[' : '[') + currentTable + (tableMatch[1] ? ']]' : ']')); + continue; + } + const kvMatch = line.match(/^([A-Za-z_][\w-]*)\s*=\s*(.*)$/); + if (kvMatch) { + const key = currentTable ? `${currentTable}.${kvMatch[1]}` : kvMatch[1]; + const value = kvMatch[2].replace(/^"(.*)"$/, '$1'); + keys.set(key, value); + } + } + return { tableHeaders, keys }; +} + +const SHAPES = { + 'Shape 1 (legacy gsd-update-check)': [ + '# GSD Hooks', + '[[hooks]]', + 'event = "SessionStart"', + 'command = "node /Users/USER/.codex/hooks/gsd-update-check.js"', + ].join('\n'), + 'Shape 2 (flat [[hooks]] + gsd-check-update)': [ + '# GSD Hooks', + '[[hooks]]', + 'event = "SessionStart"', + 'command = "node /Users/USER/.codex/hooks/gsd-check-update.js"', + ].join('\n'), + 'Shape 3 ([[hooks.SessionStart]] without nested .hooks)': [ + '# GSD Hooks', + '[[hooks.SessionStart]]', + 'command = "node /Users/USER/.codex/hooks/gsd-check-update.js"', + ].join('\n'), + 'Shape 4 (nested [[hooks.SessionStart]] + [[hooks.SessionStart.hooks]])': [ + '# GSD Hooks', + '[[hooks.SessionStart]]', + '', + '[[hooks.SessionStart.hooks]]', + 'type = "command"', + 'command = "node /Users/USER/.codex/hooks/gsd-check-update.js"', + ].join('\n'), +}; + +describe('bug-2866: stripStaleGsdHookBlocks handles end-of-file without trailing newline', () => { + test('stripStaleGsdHookBlocks is exported from bin/install.js', () => { + assert.strictEqual(typeof stripStaleGsdHookBlocks, 'function', + 'bin/install.js must export stripStaleGsdHookBlocks'); + }); + + function assertStripped(out, shape, scenario) { + const shape_ = parseTomlShape(out); + const hooksTable = shape_.tableHeaders.find((h) => /^\[\[?hooks(\.|]\])/.test(h)); + assert.strictEqual(hooksTable, undefined, + `(${shape}, ${scenario}) no hooks table header may remain after strip, got tables: ${shape_.tableHeaders.join(', ')}`); + const staleCmd = [...shape_.keys.entries()].find(([_, v]) => + /gsd-(update-check|check-update)/.test(v)); + assert.strictEqual(staleCmd, undefined, + `(${shape}, ${scenario}) no key may carry a stale gsd-*-update command, got: ${staleCmd && staleCmd.join('=')}`); + assert.strictEqual(shape_.keys.get('history.persistence'), 'save-all', + `(${shape}, ${scenario}) history.persistence must be preserved as "save-all"`); + } + + for (const [shape, block] of Object.entries(SHAPES)) { + test(`${shape}: stripped when terminated by trailing newline`, () => { + const input = `[history]\npersistence = "save-all"\n${block}\n`; + assertStripped(stripStaleGsdHookBlocks(input), shape, 'with trailing newline'); + }); + + test(`${shape}: stripped when at end-of-file without trailing newline`, () => { + // The reporter's repro: stale block sits at the very end with no \n. + const input = `[history]\npersistence = "save-all"\n${block}`; + assertStripped(stripStaleGsdHookBlocks(input), shape, 'no trailing newline'); + }); + } + + test('returns input unchanged when no GSD hook block is present', () => { + const benign = '[history]\npersistence = "save-all"\n'; + const out = stripStaleGsdHookBlocks(benign); + assert.strictEqual(out, benign, 'helper must be a no-op when no GSD reference exists'); + const benignShape = parseTomlShape(out); + assert.strictEqual(benignShape.keys.get('history.persistence'), 'save-all', + 'parsed shape must preserve history.persistence'); + assert.deepStrictEqual(benignShape.tableHeaders, ['[history]'], + 'parsed shape must contain only the [history] table'); + }); + + // The structural rewrite (TOML-AST-driven, not regex-driven) must handle + // whitespace and key-ordering variations that the previous regex missed. + // These cases were silently leaked by the old implementation; one + // (V3) actually corrupted the file by leaving an orphaned key=value line + // outside any table. + const VARIATIONS = { + 'extra blank line in Shape 4': [ + '# GSD Hooks', + '[[hooks.SessionStart]]', + '', + '', + '[[hooks.SessionStart.hooks]]', + 'type = "command"', + 'command = "node /Users/USER/.codex/hooks/gsd-check-update.js"', + ].join('\n'), + 'keys reordered (command before event in Shape 2)': [ + '# GSD Hooks', + '[[hooks]]', + 'command = "node /Users/USER/.codex/hooks/gsd-check-update.js"', + 'event = "SessionStart"', + ].join('\n'), + 'extra key alongside command (Shape 3 + timeout)': [ + '# GSD Hooks', + '[[hooks.SessionStart]]', + 'command = "node /Users/USER/.codex/hooks/gsd-check-update.js"', + 'timeout = 5000', + ].join('\n'), + 'tight whitespace (no spaces around `=`)': [ + '# GSD Hooks', + '[[hooks]]', + 'event="SessionStart"', + 'command="node /Users/USER/.codex/hooks/gsd-check-update.js"', + ].join('\n'), + }; + + for (const [variation, block] of Object.entries(VARIATIONS)) { + test(`variation stripped: ${variation}`, () => { + const input = `[history]\npersistence = "save-all"\n${block}\n`; + assertStripped(stripStaleGsdHookBlocks(input), variation, 'with trailing newline'); + }); + test(`variation stripped at EOF without trailing newline: ${variation}`, () => { + const input = `[history]\npersistence = "save-all"\n${block}`; + assertStripped(stripStaleGsdHookBlocks(input), variation, 'no trailing newline'); + }); + } + + test('user-authored [[hooks.UserPromptSubmit]] is preserved', () => { + // The structural strip must not touch hook tables that don't carry a + // GSD-managed `gsd-(check-update|update-check).js` command. + const input = [ + '[history]', + 'persistence = "save-all"', + '[[hooks.UserPromptSubmit]]', + 'command = "node /Users/USER/my-hook.js"', + '', + ].join('\n'); + const out = stripStaleGsdHookBlocks(input); + const shape = parseTomlShape(out); + assert.ok( + shape.tableHeaders.includes('[[hooks.UserPromptSubmit]]'), + `user-authored [[hooks.UserPromptSubmit]] must survive, got: ${shape.tableHeaders.join(', ')}`, + ); + assert.strictEqual( + shape.keys.get('hooks.UserPromptSubmit.command'), + 'node /Users/USER/my-hook.js', + 'user-authored command value must be preserved verbatim', + ); + }); + + test('Shape 4 strip does not leave an orphaned [[hooks.SessionStart]] header', () => { + // Shape 4 is stripped before Shape 3 specifically to avoid this. + const block = SHAPES['Shape 4 (nested [[hooks.SessionStart]] + [[hooks.SessionStart.hooks]])']; + const out = stripStaleGsdHookBlocks(`[history]\npersistence = "save-all"\n${block}`); + const outShape = parseTomlShape(out); + const orphan = outShape.tableHeaders.find((h) => /hooks\.SessionStart/.test(h)); + assert.strictEqual(orphan, undefined, + `Shape 4 strip must remove the parent [[hooks.SessionStart]] header too, got tables: ${outShape.tableHeaders.join(', ')}`); + }); +}); + }); +} + +// ─── #2586: stop installing Codex context-monitor hooks without metrics ──── +// gsd-context-monitor.js reads a statusline bridge file Codex never writes, +// so every registered event was a guaranteed silent no-op. These tests drive +// the REAL install()/uninstall() entry points against a real temp CODEX_HOME +// — never a hand-fabricated manifest (see PR #2709's Blocker 1: its cleanup +// path was unreachable in production because its tests only exercised a +// fixture, not real installer state). +describe('#2586 Codex context-monitor: stop installing, clean up on reinstall', () => { + const { + cleanupOrphanedCodexContextMonitorScript, + isGsdOwnedCodexContextMonitorScript, + hooksJsonReferencesCodexContextMonitor, + } = require('../gsd-core/bin/lib/runtime-hooks-surface.cjs'); + + let codexHome; + + beforeEach(() => { + codexHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-codex-2586-')); + }); + + afterEach(() => { + cleanup(codexHome); + delete process.env.GSD_ALLOW_SYMLINKED_DEST; + }); + + function hooksJsonPath(home) { + return path.join(home, 'hooks.json'); + } + + function readHooksJson(home) { + const raw = fs.readFileSync(hooksJsonPath(home), 'utf8'); + return JSON.parse(raw); + } + + function monitorScriptPath(home) { + return path.join(home, 'hooks', 'gsd-context-monitor.js'); + } + + function monitorCmdShimPath(home) { + return path.join(home, 'hooks', 'gsd-context-monitor.cmd'); + } + + // uninstall(), unlike install(), does not sandbox CODEX_HOME/HOME itself — + // runCodexInstall's own env-restore in its `finally` means those are back + // to the real environment by the time a bare `uninstall(true, 'codex')` + // would run. Mirrors runCodexInstall's own sandboxing exactly so uninstall + // operates on the temp fixture, never the real ~/.codex. + function runCodexUninstall(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; + process.env.HOME = codexHome; + process.env.USERPROFILE = codexHome; + try { + process.chdir(cwd); + return uninstall(true, 'codex'); + } finally { + process.chdir(previousCwd); + 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; + } + } + + // The exact shape a real pre-#2586 install would have written: the shipped + // gsd-context-monitor.js content (with its ownership markers intact) plus + // hooks.json registrations for every CODEX_EXTENDED_HOOK_EVENTS member, + // using the same command-projection shape ensureCodexHooksJsonEvent used + // to write. Built from the real resolveNodeRunner() output, not a literal + // guess at the command string, so a drift in projectManagedHookCommand's + // output shape cannot make this fixture silently stop matching reality. + function seedPreExisting2586Install(home) { + fs.mkdirSync(path.join(home, 'hooks'), { recursive: true }); + // A literal fixture carrying the same ownership markers + // isGsdOwnedCodexContextMonitorScript looks for, built as a string + // (never read+string-matched from the real shipped source file — see + // this repo's "no source grep" test rule). + const fixtureContent = [ + '#!/usr/bin/env node', + '// gsd-hook-version: 1.12.0', + '// Context Monitor - PostToolUse/AfterTool hook', + 'process.exit(0);', + '', + ].join('\n'); + fs.writeFileSync(monitorScriptPath(home), fixtureContent, 'utf8'); + const runner = resolveNodeRunner(); + const hooksSurface = require('../gsd-core/bin/lib/runtime-hooks-surface.cjs'); + for (const eventName of CODEX_EXTENDED_HOOK_EVENTS) { + hooksSurface.ensureCodexHooksJsonEvent(home, eventName, { + absoluteRunner: runner, + platform: process.platform, + }); + } + } + + test('fresh install does not copy gsd-context-monitor.js or register any extended event', () => { + runCodexInstall(codexHome); + assert.strictEqual(fs.existsSync(monitorScriptPath(codexHome)), false, + 'gsd-context-monitor.js must not be copied on a fresh Codex install'); + assert.strictEqual(fs.existsSync(monitorCmdShimPath(codexHome)), false); + assert.strictEqual(fs.existsSync(path.join(codexHome, 'hooks', 'lib', 'hook-exit.js')), false, + 'hook-exit.js is only required by gsd-context-monitor.js — nothing else staged should pull it in'); + assert.ok(fs.existsSync(path.join(codexHome, 'hooks', 'gsd-check-update.js')), + 'gsd-check-update.js must still be staged'); + assert.ok(fs.existsSync(path.join(codexHome, 'hooks', 'gsd-check-update-worker.js'))); + assert.ok(fs.existsSync(path.join(codexHome, 'hooks', 'managed-hooks-registry.cjs'))); + if (fs.existsSync(hooksJsonPath(codexHome))) { + assert.strictEqual(hooksJsonReferencesCodexContextMonitor(codexHome), false); + } + }); + + test('reinstall removes exact pre-#2586 registrations for every extended event and deletes the orphaned script', () => { + runCodexInstall(codexHome); + seedPreExisting2586Install(codexHome); + assert.ok(fs.existsSync(monitorScriptPath(codexHome)), 'fixture sanity: script seeded'); + assert.strictEqual(hooksJsonReferencesCodexContextMonitor(codexHome), true, 'fixture sanity: registered'); + + runCodexInstall(codexHome); + + assert.strictEqual(fs.existsSync(monitorScriptPath(codexHome)), false, + 'orphaned gsd-context-monitor.js must be removed once unreferenced and GSD-owned'); + assert.strictEqual(hooksJsonReferencesCodexContextMonitor(codexHome), false, + 'no hooks.json entry may still reference gsd-context-monitor after reinstall'); + // gsd-check-update's own SessionStart registration must survive untouched. + const hooks = readHooksJson(codexHome); + const sessionStart = (hooks.hooks && hooks.hooks.SessionStart) || []; + const hasCheckUpdate = sessionStart.some((entry) => + (entry.hooks || []).some((h) => typeof h.command === 'string' && /gsd-check-update/.test(h.command))); + assert.ok(hasCheckUpdate, 'gsd-check-update SessionStart registration must remain after cleanup'); + }); + + test('reinstall preserves a hand-customized registration and does not delete a still-referenced script', () => { + runCodexInstall(codexHome); + seedPreExisting2586Install(codexHome); + // Hand-edit ONE event's entry to a shape isManagedHookCommand will not + // recognize (wraps the invocation in a shell script it does not know). + const before = readHooksJson(codexHome); + before.hooks.Stop = [{ hooks: [{ type: 'command', command: 'bash -c "/opt/custom/my-wrapper.sh"' }] }]; + fs.writeFileSync(hooksJsonPath(codexHome), JSON.stringify(before, null, 2) + '\n', 'utf8'); + + runCodexInstall(codexHome); + + const after = readHooksJson(codexHome); + assert.deepStrictEqual(after.hooks.Stop, before.hooks.Stop, + 'a hand-customized registration must survive verbatim'); + }); + + test('reinstall leaves unrelated hooks.json events and config.toml keys untouched', () => { + runCodexInstall(codexHome); + let hooks = fs.existsSync(hooksJsonPath(codexHome)) ? readHooksJson(codexHome) : { hooks: {} }; + if (!hooks.hooks) hooks.hooks = {}; + hooks.hooks.UnrelatedEvent = [{ hooks: [{ type: 'command', command: 'echo unrelated' }] }]; + fs.writeFileSync(hooksJsonPath(codexHome), JSON.stringify(hooks, null, 2) + '\n', 'utf8'); + const configPath = path.join(codexHome, 'config.toml'); + const configBefore = fs.readFileSync(configPath, 'utf8') + '\n[my_unrelated_section]\nfoo = "bar"\n'; + fs.writeFileSync(configPath, configBefore, 'utf8'); + + runCodexInstall(codexHome); + + const after = readHooksJson(codexHome); + assert.deepStrictEqual(after.hooks.UnrelatedEvent, hooks.hooks.UnrelatedEvent, + 'an unrelated event array must be untouched'); + const configAfter = fs.readFileSync(configPath, 'utf8'); + assert.ok(configAfter.includes('[my_unrelated_section]\nfoo = "bar"'), + 'unrelated config.toml section must survive a Codex reinstall'); + }); + + test('a user-owned pre-existing EMPTY event array is preserved, not deleted', () => { + runCodexInstall(codexHome); + fs.writeFileSync(hooksJsonPath(codexHome), JSON.stringify({ hooks: { Stop: [] } }, null, 2) + '\n', 'utf8'); + + runCodexInstall(codexHome); + + const after = readHooksJson(codexHome); + assert.ok(Array.isArray(after.hooks.Stop) && after.hooks.Stop.length === 0, + 'an empty array the user already had must not be dropped by cleanup that found nothing GSD-owned to remove'); + }); + + test('symlinked hooks.json aborts before any Codex change, without GSD_ALLOW_SYMLINKED_DEST', () => { + runCodexInstall(codexHome); + const configPath = path.join(codexHome, 'config.toml'); + const configBefore = fs.readFileSync(configPath, 'utf8'); + const realHooksJson = path.join(codexHome, 'real-hooks.json'); + fs.writeFileSync(realHooksJson, JSON.stringify({ hooks: {} }, null, 2) + '\n', 'utf8'); + fs.unlinkSync(hooksJsonPath(codexHome)); + fs.symlinkSync(realHooksJson, hooksJsonPath(codexHome)); + + assert.throws(() => runCodexInstall(codexHome), /symlink/i); + + assert.ok(fs.lstatSync(hooksJsonPath(codexHome)).isSymbolicLink(), + 'the symlink itself must survive an aborted install — never replaced by a plain file'); + assert.strictEqual(fs.readFileSync(configPath, 'utf8'), configBefore, + 'config.toml must be restored to its pre-attempt snapshot on abort'); + }); + + test('symlinked hooks.json is followed when GSD_ALLOW_SYMLINKED_DEST=1', () => { + runCodexInstall(codexHome); + const realHooksJson = path.join(codexHome, 'real-hooks.json'); + fs.writeFileSync(realHooksJson, JSON.stringify({ hooks: {} }, null, 2) + '\n', 'utf8'); + fs.unlinkSync(hooksJsonPath(codexHome)); + fs.symlinkSync(realHooksJson, hooksJsonPath(codexHome)); + process.env.GSD_ALLOW_SYMLINKED_DEST = '1'; + + assert.doesNotThrow(() => runCodexInstall(codexHome)); + + assert.ok(fs.lstatSync(hooksJsonPath(codexHome)).isSymbolicLink(), 'still a symlink afterward'); + assert.ok(fs.existsSync(realHooksJson), 'the symlink target must have been written through'); + }); + + test('cleanupOrphanedCodexContextMonitorScript keeps the hooks.json deregistration when script deletion fails', () => { + runCodexInstall(codexHome); + seedPreExisting2586Install(codexHome); + for (const eventName of CODEX_EXTENDED_HOOK_EVENTS) { + require('../gsd-core/bin/lib/runtime-hooks-surface.cjs').removeCodexHooksJsonEvent(codexHome, eventName); + } + assert.strictEqual(hooksJsonReferencesCodexContextMonitor(codexHome), false, 'deregistration committed first'); + + const originalUnlinkSync = fs.unlinkSync; + fs.unlinkSync = (target, ...rest) => { + if (typeof target === 'string' && target.includes('gsd-context-monitor')) { + throw Object.assign(new Error('EPERM: simulated'), { code: 'EPERM' }); + } + return originalUnlinkSync.call(fs, target, ...rest); + }; + // Determine which GSD-owned candidates actually exist BEFORE the mocked + // deletion attempt — on Windows, ensureCodexHooksJsonEvent also staged a + // .cmd shim alongside the .js file (see buildCodexHookWindowsShimIR), so + // both deletions fail under the mock above; on POSIX only the .js file + // exists. Asserting against this rather than a hardcoded 1 keeps the row + // meaningful on both platforms instead of just loosening it to "at least + // one" (see CI failure: Windows reported 2 warnings, not 1). + const cmdShimPath = monitorCmdShimPath(codexHome); + const cmdShimExisted = fs.existsSync(cmdShimPath); + let result; + try { + result = cleanupOrphanedCodexContextMonitorScript(codexHome); + } finally { + fs.unlinkSync = originalUnlinkSync; + } + + const expectedWarningCount = cmdShimExisted ? 2 : 1; + assert.strictEqual(result.warnings.length, expectedWarningCount); + assert.ok(result.warnings.some((w) => /gsd-context-monitor\.js$/.test(w.path)), + 'a warning must name the .js script'); + if (cmdShimExisted) { + assert.ok(result.warnings.some((w) => /gsd-context-monitor\.cmd$/.test(w.path)), + 'a warning must name the .cmd shim when Windows staged one'); + assert.ok(fs.existsSync(cmdShimPath), 'the .cmd shim remains on disk since its deletion failed too'); + } + assert.strictEqual(hooksJsonReferencesCodexContextMonitor(codexHome), false, + 'the already-safe hooks.json deregistration must not be reverted by a script-deletion failure'); + assert.ok(fs.existsSync(monitorScriptPath(codexHome)), 'the file remains on disk since deletion failed'); + }); + + test('isGsdOwnedCodexContextMonitorScript rejects a user file at the same path', () => { + runCodexInstall(codexHome); + fs.mkdirSync(path.join(codexHome, 'hooks'), { recursive: true }); + fs.writeFileSync(monitorScriptPath(codexHome), '#!/usr/bin/env node\nconsole.log("my own script");\n', 'utf8'); + assert.strictEqual(isGsdOwnedCodexContextMonitorScript(monitorScriptPath(codexHome)), false); + }); + + test('uninstall removes recognized registrations and the orphaned script symmetrically with install', () => { + runCodexInstall(codexHome); + seedPreExisting2586Install(codexHome); + + runCodexUninstall(codexHome); + + assert.strictEqual(fs.existsSync(monitorScriptPath(codexHome)), false); + if (fs.existsSync(hooksJsonPath(codexHome))) { + assert.strictEqual(hooksJsonReferencesCodexContextMonitor(codexHome), false); + } + }); + + test('uninstall does not throw on an unmodeled hooks.json event-value shape', () => { + runCodexInstall(codexHome); + fs.writeFileSync(hooksJsonPath(codexHome), JSON.stringify({ hooks: { Stop: 'not-an-array' } }, null, 2) + '\n', 'utf8'); + assert.doesNotThrow(() => runCodexUninstall(codexHome)); + }); + + test('property: reconcileCodexHooksJsonEvent never removes a non-managed-shape command', () => { + const { reconcileCodexHooksJsonEvent } = require('../gsd-core/bin/lib/runtime-hooks-surface.cjs'); + fc.assert( + fc.property( + fc.array(fc.string({ minLength: 1, maxLength: 40 }).filter((s) => !/gsd-context-monitor|gsd-check-update/.test(s)), { minLength: 1, maxLength: 5 }), + (customCommands) => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-codex-2586-prop-')); + try { + const seeded = { hooks: { Stop: [{ hooks: customCommands.map((c) => ({ type: 'command', command: c })) }] } }; + fs.writeFileSync(path.join(home, 'hooks.json'), JSON.stringify(seeded, null, 2) + '\n', 'utf8'); + reconcileCodexHooksJsonEvent(home, 'Stop', { managedCommand: null }); + const after = JSON.parse(fs.readFileSync(path.join(home, 'hooks.json'), 'utf8')); + const survivingCommands = ((after.hooks && after.hooks.Stop) || []) + .flatMap((entry) => (entry.hooks || []).map((h) => h.command)); + for (const c of customCommands) { + assert.ok(survivingCommands.includes(c), `non-managed command "${c}" must survive removal`); + } + } finally { + cleanup(home); + } + }, + ), + { numRuns: 25 }, + ); + }); +}); diff --git a/tests/codex-config.test.cjs b/tests/codex-config.test.cjs index f67306355..fab8e963e 100644 --- a/tests/codex-config.test.cjs +++ b/tests/codex-config.test.cjs @@ -29,10 +29,11 @@ const modelResolver = require('../gsd-core/bin/lib/model-resolver.cjs'); // #2153 follow-up: ensure hooks/dist/ exists before any install integration // test runs. The Codex install path copies hook files from hooks/dist/, which -// is gitignored and only populated by `npm run build:hooks`. When this file is -// run in isolation (`node --test tests/codex-config.test.cjs`) the build step -// from the npm-test pretest chain does not run, and the "Codex install copies -// hook file" regression silently fails because hooks/dist/ is empty. +// is gitignored and only populated by `npm run build:hooks`. When one of the +// codex-config*.test.cjs files is run in isolation (`node --test +// tests/codex-config-agents.test.cjs`, for example) the build step from the +// npm-test pretest chain does not run, and the "Codex install copies hook +// file" regression silently fails because hooks/dist/ is empty. // Build on demand so the test passes regardless of runner ordering. const HOOKS_DIST = path.join(__dirname, '..', 'hooks', 'dist'); const BUILD_HOOKS_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); @@ -55,7 +56,7 @@ const { convertClaudeCommandToCodexSkill, generateCodexAgentToml, _resetCodexWarningDedupeForTests, - cleanupCodexSkillMetadataSidecars, + cleanupCodexSkillMetadataSidecars: _cleanupCodexSkillMetadataSidecars, generateCodexConfigBlock, stripGsdFromCodexConfig, migrateCodexHooksMapFormat, @@ -73,11 +74,11 @@ const { CODEX_SANDBOX_HOLDS, parseTomlToObject, validateCodexConfigSchema, - uninstall, - CODEX_EXTENDED_HOOK_EVENTS, + uninstall: _uninstall, + CODEX_EXTENDED_HOOK_EVENTS: _CODEX_EXTENDED_HOOK_EVENTS, } = require('../bin/install.js'); -const { resolveNodeRunner } = require('../gsd-core/bin/lib/runtime-hooks-surface.cjs'); +const { resolveNodeRunner: _resolveNodeRunner } = require('../gsd-core/bin/lib/runtime-hooks-surface.cjs'); const { resolveInstallPlan } = require('../gsd-core/bin/lib/runtime-config-adapter-registry.cjs'); // #3897 fixup: deriveCodexSandboxMode's 2nd param is now the already-resolved // `tools:` frontmatter VALUE, not raw agent content (codex-agent-toml.cjs no @@ -96,7 +97,7 @@ const { // fix-divergence shape this fix closes). const { extractToolsValue } = require('../gsd-core/bin/lib/codex-agent-toml.cjs'); -function runCodexInstall(codexHome, cwd = path.join(__dirname, '..')) { +function _runCodexInstall(codexHome, cwd = path.join(__dirname, '..')) { const previousCodeHome = process.env.CODEX_HOME; const previousHome = process.env.HOME; const previousUserProfile = process.env.USERPROFILE; @@ -123,20 +124,20 @@ function runCodexInstall(codexHome, cwd = path.join(__dirname, '..')) { } } // #2088: the canonical Codex skill-install root, sandboxed under codexHome. -function codexSkillsRoot(codexHome) { +function _codexSkillsRoot(codexHome) { return path.join(codexHome, '.agents', 'skills'); } -function readCodexConfig(codexHome) { +function _readCodexConfig(codexHome) { return fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8'); } -function writeCodexConfig(codexHome, content) { +function _writeCodexConfig(codexHome, content) { fs.mkdirSync(codexHome, { recursive: true }); fs.writeFileSync(path.join(codexHome, 'config.toml'), content, 'utf8'); } -function readHooksSessionStartCommands(codexHome) { +function _readHooksSessionStartCommands(codexHome) { const hooksPath = path.join(codexHome, 'hooks.json'); if (!fs.existsSync(hooksPath)) return []; const raw = fs.readFileSync(hooksPath, 'utf8').trim(); @@ -158,7 +159,7 @@ function countMatches(content, pattern) { return (content.match(pattern) || []).length; } -function assertNoDraftRootKeys(content) { +function _assertNoDraftRootKeys(content) { assert.ok(!content.includes('model = "gpt-5.6-terra"'), 'does not inject draft model default'); assert.ok(!content.includes('model_reasoning_effort = "high"'), 'does not inject draft reasoning default'); assert.ok(!content.includes('disable_response_storage = true'), 'does not inject draft storage default'); @@ -191,6 +192,7 @@ function assertNoCodexBareGsdToolsInvocation(content, label) { // ─── getCodexSkillAdapterHeader ───────────────────────────────────────────────── + describe('getCodexSkillAdapterHeader', () => { test('contains all three sections', () => { const result = getCodexSkillAdapterHeader('gsd-execute-phase'); @@ -2974,8288 +2976,3 @@ describe('mergeCodexConfig', () => { assert.ok(content.indexOf('notify = ') < content.indexOf('[agents]'), 'hoist holds under CRLF'); }); }); - - -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/issue-2940-codex-config-merge-trailing.test.cjs — consolidation epic #1969 (H3 W4 #3336) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:bug-2940-codex-config-merge-trailing (consolidation epic #1969 H3 W4 #3336)", () => { -'use strict'; -process.env.GSD_TEST_MODE = '1'; - -/** - * Regression test for #2940 — `gsd-update` overwrites `~/.codex/config.toml`, - * removing any user/Codex-CLI settings added after the GSD-managed marker block. - * - * Root cause: `mergeCodexConfig`'s Case 2 (marker present) preserved content - * BEFORE the marker but unconditionally discarded everything from the marker to - * EOF, replacing it with a freshly generated GSD block. Since a fresh install - * writes the GSD block as the file's entire content, any settings the user or - * Codex CLI later adds (`[model]`, `[mcp_servers.*]`, `[profiles.*]`) land AFTER - * the block, and every subsequent update wiped them. - * - * The fix preserves genuine trailing TOML by routing the post-marker region - * through the existing `stripLeakedGsdCodexSections` (which removes GSD's own - * managed/leaked sections while keeping user tables), then re-appending it after - * the regenerated GSD block — without regressing #2406's de-dup. - * - * Matrix: .gsd/bug/fix/2940-codex-config-merge-preserves-trailing-content/50-test-matrix.md - * - * NOTE: this describe block covers trailing-content-after-the-marker preservation - * ([model]/[mcp_servers.*]/[profiles.*] appended AFTER the GSD block) — a case the - * pre-existing 'mergeCodexConfig' suite above does not exercise (that suite's cases - * write user content BEFORE the marker/block, not after). Verified non-duplicate - * against both the pre-existing target and the other three folded sources. - */ - -const { describe, test, beforeEach, afterEach } = 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 { cleanup } = require('./helpers.cjs'); - -const { - generateCodexConfigBlock, - mergeCodexConfig, - GSD_CODEX_MARKER, -} = require('../bin/install.js'); - -describe('mergeCodexConfig trailing-content preservation (#2940)', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2940-merge-')); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - /** A GSD block with one agent (the shape installCodexConfig passes). */ - const block = () => - generateCodexConfigBlock([{ name: 'gsd-executor', description: 'Executes plans' }]); - - test('trailingUserModelSectionPreserved', () => { - // Row 1 (failing-first regression): a config with the GSD block FIRST, then a user - // [model] section after it (the real-world layout — fresh install fills the file, - // user settings land after). Re-merge must preserve [model] byte-for-byte. - const configPath = path.join(tmpDir, 'config.toml'); - const trailing = '[model]\nname = "gpt-5.4"\n'; - // First write: GSD block + user content after it (no content before the marker). - fs.writeFileSync(configPath, block() + '\n' + trailing); - - mergeCodexConfig(configPath, block()); - - const content = fs.readFileSync(configPath, 'utf8'); - assert.ok(content.includes('[model]'), 'user [model] section preserved after re-merge'); - assert.ok(content.includes('name = "gpt-5.4"'), 'user model value preserved verbatim'); - assert.ok(content.includes(GSD_CODEX_MARKER), 'GSD marker still present'); - const markerCount = (content.match(new RegExp(escapeRegex(GSD_CODEX_MARKER), 'g')) || []).length; - assert.strictEqual(markerCount, 1, 'exactly one marker (no duplication)'); - assert.ok(content.includes('max_depth ='), 'GSD-managed [agents] block regenerated'); - }); - - test('multipleTrailingTablesPreserved', () => { - // Row 2: multiple trailing user tables ([mcp_servers.*], [profiles.*]). - const configPath = path.join(tmpDir, 'config.toml'); - const trailing = [ - '[mcp_servers.figma]', - 'command = "npx"', - 'args = ["-y", "figma-mcp"]', - '', - '[profiles.dev]', - 'model = "o3"', - 'sandbox_mode = "workspace-write"', - ].join('\n'); - fs.writeFileSync(configPath, block() + '\n' + trailing + '\n'); - - mergeCodexConfig(configPath, block()); - - const content = fs.readFileSync(configPath, 'utf8'); - assert.ok(content.includes('[mcp_servers.figma]'), 'mcp_servers table preserved'); - assert.ok(content.includes('[profiles.dev]'), 'profiles table preserved'); - assert.ok(content.includes('sandbox_mode = "workspace-write"'), 'profile value preserved'); - assert.ok(content.includes(GSD_CODEX_MARKER), 'GSD block regenerated'); - }); - - test('reMergeIsIdempotent', () => { - // Row 3 (acceptance #2): merging the result of a merge again yields identical content. - const configPath = path.join(tmpDir, 'config.toml'); - fs.writeFileSync(configPath, block() + '\n[model]\nname = "o3"\n'); - - mergeCodexConfig(configPath, block()); - const afterFirst = fs.readFileSync(configPath, 'utf8'); - - mergeCodexConfig(configPath, block()); - const afterSecond = fs.readFileSync(configPath, 'utf8'); - - assert.strictEqual(afterSecond, afterFirst, 'second merge is idempotent (no further change)'); - }); - - test('leakedGsdSectionAfterMarkerStillStripped', () => { - // Row 4 (#2406 non-regression): a leaked GSD-managed [agents.gsd-*] section AFTER the - // marker is still REMOVED (not regrown), while genuine user content after it is preserved. - const configPath = path.join(tmpDir, 'config.toml'); - const leakedAndUser = [ - '[agents.gsd-executor]', - 'description = "stale leaked"', - 'config_file = "agents/gsd-executor.toml"', - '', - '[model]', - 'name = "o3"', - ].join('\n'); - fs.writeFileSync(configPath, block() + '\n' + leakedAndUser + '\n'); - - mergeCodexConfig(configPath, block()); - - const content = fs.readFileSync(configPath, 'utf8'); - const gsdStructCount = (content.match(/^\[agents\.gsd-executor\]\s*$/gm) || []).length; - assert.strictEqual(gsdStructCount, 0, 'leaked [agents.gsd-executor] after marker is stripped (not regrown)'); - assert.ok(content.includes('[model]'), 'genuine user [model] after the leaked section still preserved'); - }); - - test('bareAgentsAfterMarkerHandled', () => { - // Row 5: a user AgentsToml scalar (max_threads) the user folded INTO the managed [agents] - // block (the valid, realistic shape — two [agents] tables would be invalid TOML), PLUS a - // separate trailing [model] section. The fix must preserve the user scalar via the existing - // spliceCodexAgentsScalars path AND preserve the trailing [model] via the new trailing-region - // logic, while regenerating exactly one managed [agents] table. - const configPath = path.join(tmpDir, 'config.toml'); - // Simulate: fresh install wrote the GSD block; the user then added max_threads into the - // [agents] table and added a [model] section after it. - const existing = [ - GSD_CODEX_MARKER, - '', - '[agents]', - 'max_depth = 1', - 'max_threads = 4', - '', - '[model]', - 'name = "o3"', - ].join('\n'); - fs.writeFileSync(configPath, existing + '\n'); - - mergeCodexConfig(configPath, block()); - - const content = fs.readFileSync(configPath, 'utf8'); - // The user's max_threads scalar is preserved (spliced into the regenerated managed [agents]); - // there is exactly one [agents] table (the managed one). - assert.ok(content.includes('max_threads = 4'), 'user AgentsToml scalar (max_threads) preserved in managed block'); - const agentsHeaders = (content.match(/^\[agents\]\s*$/gm) || []).length; - assert.strictEqual(agentsHeaders, 1, 'exactly one [agents] table (the managed one)'); - assert.ok(content.includes('max_depth = 1'), 'GSD-managed max_depth still present'); - assert.ok(content.includes('[model]'), 'trailing [model] still preserved'); - }); - - test('beforeAndAfterMarkerBothPreserved', () => { - // Row 6: content both BEFORE and AFTER the marker is preserved; GSD block regenerated once. - const configPath = path.join(tmpDir, 'config.toml'); - const before = '[profiles.work]\nmodel = "gpt-5.4"\n'; - const after = '[mcp_servers.github]\ncommand = "gh-mcp"\n'; - fs.writeFileSync(configPath, before + '\n' + block() + '\n' + after + '\n'); - - mergeCodexConfig(configPath, block()); - - const content = fs.readFileSync(configPath, 'utf8'); - assert.ok(content.includes('[profiles.work]'), 'content before marker preserved'); - assert.ok(content.includes('[mcp_servers.github]'), 'content after marker preserved'); - const markerCount = (content.match(new RegExp(escapeRegex(GSD_CODEX_MARKER), 'g')) || []).length; - assert.strictEqual(markerCount, 1, 'exactly one marker'); - }); - - test('noTrailingContentUnchanged', () => { - // Row 7 (zero-trailing boundary): a config with ONLY the GSD block (fresh-install case) - // re-merges to just the regenerated block — no spurious blank-line artifacts introduced - // by the trailing-preservation logic. - const configPath = path.join(tmpDir, 'config.toml'); - fs.writeFileSync(configPath, block() + '\n'); - - mergeCodexConfig(configPath, block()); - - const content = fs.readFileSync(configPath, 'utf8'); - // No spurious trailing blank lines beyond the single trailing newline. Use a CRLF-safe - // pattern (\r?\n) so the assertion holds under Windows git-autocrlf line endings. - assert.ok(!/(?:\r?\n){3,}$/.test(content), 'no spurious run of blank lines at end of file'); - assert.strictEqual(content.trim(), block().trim(), 'content is exactly the regenerated block (whitespace-trimmed)'); - }); -}); - }); -} - - -// ─── Integration: installCodexConfig ──────────────────────────────────────────── - -describe('installCodexConfig (integration)', () => { - let tmpTarget; - const agentsSrc = path.join(__dirname, '..', 'agents'); - - beforeEach(() => { - tmpTarget = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-codex-install-')); - }); - - afterEach(() => { - cleanup(tmpTarget); - }); - - // Only run if agents/ directory exists (not in CI without full checkout) - const hasAgents = fs.existsSync(agentsSrc); - - (hasAgents ? test : test.skip)('generates config.toml and agent .toml files', () => { - const { installCodexConfig } = require('../bin/install.js'); - const count = installCodexConfig(tmpTarget, agentsSrc); - - assert.ok(count >= 11, `installed ${count} agents (expected >= 11)`); - - // Verify config.toml - const configPath = path.join(tmpTarget, 'config.toml'); - assert.ok(fs.existsSync(configPath), 'config.toml exists'); - const config = fs.readFileSync(configPath, 'utf8'); - assert.ok(config.includes(GSD_CODEX_MARKER), 'has GSD marker'); - // #2406: config.toml must NOT register agent roles — the standalone - // agents/.toml (verified below) is the sole canonical source - // Codex auto-discovers. A role table here would be a second, - // duplicate registration of the same role. - assert.ok(!config.includes('[agents.gsd-executor]'), 'no executor role table in config.toml'); - assert.strictEqual((config.match(/^\[agents\.gsd-/gm) || []).length, 0, 'zero [agents.gsd-*] role tables of any kind'); - assert.strictEqual((config.match(/^config_file = /gm) || []).length, 0, 'zero config_file lines'); - assert.ok(!config.includes('multi_agent'), 'no feature flags'); - - // Verify per-agent .toml files - const agentsDir = path.join(tmpTarget, 'agents'); - assert.ok(fs.existsSync(path.join(agentsDir, 'gsd-executor.toml')), 'executor .toml exists'); - assert.ok(fs.existsSync(path.join(agentsDir, 'gsd-plan-checker.toml')), 'plan-checker .toml exists'); - - const executorToml = fs.readFileSync(path.join(agentsDir, 'gsd-executor.toml'), 'utf8'); - assert.ok(executorToml.includes('name = "gsd-executor"'), 'executor has name'); - assert.ok(executorToml.includes('description = "Executes GSD plans with atomic commits, deviation handling, checkpoint protocols, and state management. Spawned by execute-phase orchestrator or execute-plan command."'), 'executor has description'); - assert.ok(executorToml.includes('sandbox_mode = "workspace-write"'), 'executor is workspace-write'); - assert.ok(executorToml.includes('developer_instructions'), 'has developer_instructions'); - - const checkerToml = fs.readFileSync(path.join(agentsDir, 'gsd-plan-checker.toml'), 'utf8'); - assert.ok(checkerToml.includes('name = "gsd-plan-checker"'), 'plan-checker has name'); - assert.ok(checkerToml.includes('sandbox_mode = "read-only"'), 'plan-checker is read-only'); - }); - - // PATHS-01: no ~/.claude references should leak into generated .toml files (#2320) - // Covers both trailing-slash and bare end-of-string forms, and scans all .toml - // files (agents/ subdirectory + top-level config.toml if present). - (hasAgents ? test : test.skip)('generated .toml files contain no leaked ~/.claude paths (PATHS-01)', () => { - const { installCodexConfig } = require('../bin/install.js'); - installCodexConfig(tmpTarget, agentsSrc); - - // Collect all .toml files: per-agent files in agents/ plus top-level config.toml. - // Not the shared listAgentFiles() helper: reads the INSTALLED target dir and - // collects generated .toml (absolute paths), not the source .md roster. - const agentsDir = path.join(tmpTarget, 'agents'); - const tomlFiles = fs.readdirSync(agentsDir) - .filter(f => f.endsWith('.toml')) - .map(f => path.join(agentsDir, f)); - const topLevel = path.join(tmpTarget, 'config.toml'); - if (fs.existsSync(topLevel)) tomlFiles.push(topLevel); - assert.ok(tomlFiles.length > 0, 'at least one .toml file generated'); - - // Match ~/.claude, $HOME/.claude, or ./.claude with or without trailing slash - const leakPattern = /(?:~|\$HOME|\.)\/\.claude(?:\/|$)/; - const leaks = []; - for (const filePath of tomlFiles) { - const content = fs.readFileSync(filePath, 'utf8'); - if (leakPattern.test(content)) { - leaks.push(path.relative(tmpTarget, filePath)); - } - } - assert.deepStrictEqual(leaks, [], `No .toml files should contain .claude paths; found leaks in: ${leaks.join(', ')}`); - }); - - (hasAgents ? test : test.skip)('generated Codex agent .toml files do not call bare gsd-tools', () => { - const { installCodexConfig } = require('../bin/install.js'); - installCodexConfig(tmpTarget, agentsSrc); - - // Not the shared listAgentFiles() helper: reads the INSTALLED target dir and - // filters generated gsd-*.toml output, not the source .md roster. - const agentsDir = path.join(tmpTarget, 'agents'); - const tomlFiles = fs.readdirSync(agentsDir) - .filter((file) => file.startsWith('gsd-') && file.endsWith('.toml')); - assert.ok(tomlFiles.length > 0, 'expected generated Codex agent toml files'); - - for (const file of tomlFiles) { - const content = fs.readFileSync(path.join(agentsDir, file), 'utf8'); - assertNoCodexBareGsdToolsInvocation(content, `agents/${file}`); - } - }); -}); - - -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/issue-2834-codex-install-model-ordering.test.cjs — consolidation epic #1969 (H3 W4 #3336) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:bug-2834-codex-install-model-ordering (consolidation epic #1969 H3 W4 #3336)", () => { -// allow-test-rule: structural-implementation-guard (#2834) -'use strict'; - -// Regression guard for #2834: on a clean Codex install, agent TOMLs contained no -// model-routing fields because defaults.json (resolve_model_ids + runtime) was written -// AFTER installCodexConfig generated the TOMLs. The fix extracts writeNonClaudeDefaults -// and calls it BEFORE installCodexConfig. This test asserts the ordering invariant in -// the install source so a future edit can't silently re-introduce the gap. -// -// Verified non-duplicate: no existing coverage in this file asserts on -// writeNonClaudeDefaults / the install-flow call ordering (source-text guard), and -// none of the other three folded sources touch this. - -const { test } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); - -const INSTALL_JS = path.join(__dirname, '..', 'bin', 'install.js'); - -test('writeNonClaudeDefaults is called before installCodexConfig in the Codex install flow (#2834)', () => { - const src = fs.readFileSync(INSTALL_JS, 'utf8'); - - // Find the call to writeNonClaudeDefaults that precedes installCodexConfig. - const writeIdx = src.indexOf('writeNonClaudeDefaults(runtime);'); // allow-test-rule: structural-implementation-guard (#2834) - assert.ok(writeIdx !== -1, 'writeNonClaudeDefaults(runtime) must be called in the install flow'); - - // Find the FIRST installCodexConfig call AFTER the writeNonClaudeDefaults call. - const codexGenIdx = src.indexOf('installCodexConfig(targetDir', writeIdx); // allow-test-rule: structural-implementation-guard (#2834) - assert.ok(codexGenIdx !== -1 && codexGenIdx > writeIdx, - 'installCodexConfig must be called AFTER writeNonClaudeDefaults so defaults.json ' + - '(resolve_model_ids + runtime) exists before agent TOML generation reads it (#2834)'); - - // The #2834 comment must be present at the call site. - const callSite = src.slice(writeIdx - 300, writeIdx + 100); - assert.ok(/#2834/.test(callSite), 'the writeNonClaudeDefaults call must carry the #2834 rationale comment'); // allow-test-rule: structural-implementation-guard (#2834) -}); - -test('writeNonClaudeDefaults function exists and is a no-op for Claude (#2834)', () => { - const src = fs.readFileSync(INSTALL_JS, 'utf8'); - const fnIdx = src.indexOf('function writeNonClaudeDefaults('); // allow-test-rule: structural-implementation-guard (#2834) - assert.ok(fnIdx !== -1, 'writeNonClaudeDefaults must be defined as a function'); - // Bound the slice by the next top-level declaration rather than a fixed - // character count, so adding a comment or a guard inside the function cannot - // push the asserted tokens out of the window and red this test spuriously. - const nextFnIdx = src.indexOf('\nfunction ', fnIdx + 1); // allow-test-rule: structural-implementation-guard (#2834) - const fnBody = src.slice(fnIdx, nextFnIdx === -1 ? undefined : nextFnIdx); - // Source-text guard, not a behavioral call: writeNonClaudeDefaults() early-returns - // as a no-op whenever process.env.GSD_TEST_MODE is set (see its own body), and this - // suite sets GSD_TEST_MODE='1' file-wide (line 14), so invoking it here could never - // observe the resolve_model_ids/runtime writes it is supposed to make (#2834). - assert.ok(/nativeModelAliases/.test(fnBody), 'writeNonClaudeDefaults must early-return for Claude (nativeModelAliases check)'); // allow-test-rule: structural-implementation-guard (#2834) - assert.ok(/resolve_model_ids/.test(fnBody), 'writeNonClaudeDefaults must write resolve_model_ids'); // allow-test-rule: structural-implementation-guard (#2834) - assert.ok(/defaults\.runtime/.test(fnBody), 'writeNonClaudeDefaults must write runtime'); // allow-test-rule: structural-implementation-guard (#2834) -}); - }); -} - - -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/issue-2639-codex-toml-neutralization.test.cjs — consolidation epic #1969 (H3 W4 #3336) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:bug-2639-codex-toml-neutralization (consolidation epic #1969 H3 W4 #3336)", () => { -/** - * Regression: issue #2639 — Codex install generated agent TOMLs with stale - * Claude-specific references (CLAUDE.md, .claude/skills/, .claudeignore). - * - * RCA: `installCodexConfig()` applied a narrow path-only regex pass before - * calling `generateCodexAgentToml()`, bypassing the full - * `convertClaudeToCodexMarkdown()` + `neutralizeAgentReferences(..., 'AGENTS.md')` - * pipeline used on the .md emit path. Fix routes the TOML path through the - * same pipeline and extends the pipeline to cover bare `.claude/skills/`, - * `.claude/commands/`, `.claude/agents/`, and `.claudeignore`. - * - * Verified non-duplicate: the pre-existing 'generateCodexAgentToml' suite covers - * model_overrides/sandbox_mode/reasoning-effort, not CLAUDE.md/.claudeignore/skills-path - * neutralization in the emitted TOML; the '#570 — Codex leak scanner sub-bugs' suite - * covers ~/.claude path leaks via convertClaudeToCodexMarkdown but not the - * installCodexConfig()-level TOML-emit pipeline this regression targets. - */ - -process.env.GSD_TEST_MODE = '1'; - -const { test, describe, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); -const os = require('os'); - -const { installCodexConfig } = require('../bin/install.js'); -const { cleanup } = require('./helpers.cjs'); - -function makeTempDir() { - return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2639-')); -} - -function writeAgentFixture(agentsSrc, name, body) { - const content = `--- -name: ${name} -description: Test agent for #2639 ---- - -${body} -`; - fs.writeFileSync(path.join(agentsSrc, `${name}.md`), content); -} - -describe('#2639 — Codex TOML emit routes through full neutralization pipeline', () => { - let tmpDir; - let agentsSrc; - let targetDir; - - beforeEach(() => { - tmpDir = makeTempDir(); - agentsSrc = path.join(tmpDir, 'agents'); - targetDir = path.join(tmpDir, 'codex'); - fs.mkdirSync(agentsSrc, { recursive: true }); - fs.mkdirSync(targetDir, { recursive: true }); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('strips CLAUDE.md, .claude/skills/, .claude/commands/, .claude/agents/, and .claudeignore from emitted TOML', () => { - writeAgentFixture(agentsSrc, 'gsd-code-reviewer', [ - '**Project instructions:** Read `./CLAUDE.md` if it exists.', - '', - '**CLAUDE.md enforcement:** If `./CLAUDE.md` exists, treat it as hard constraints.', - '', - '**Project skills:** Check `.claude/skills/` or `.agents/skills/` directory.', - '', - 'Also check `.claude/commands/` and `.claude/agents/` for definitions.', - '', - 'DO respect .gitignore and .claudeignore. Do not review ignored files.', - '', - 'Claude will refuse the task if policy violated.', - ].join('\n')); - - installCodexConfig(targetDir, agentsSrc); - - const tomlPath = path.join(targetDir, 'agents', 'gsd-code-reviewer.toml'); - assert.ok(fs.existsSync(tomlPath), 'per-agent TOML written'); - const toml = fs.readFileSync(tomlPath, 'utf8'); - - assert.ok(!toml.includes('CLAUDE.md'), 'no CLAUDE.md references remain in TOML'); - assert.ok(!toml.includes('.claude/skills/'), 'no .claude/skills/ references remain'); - assert.ok(!toml.includes('.claude/commands/'), 'no .claude/commands/ references remain'); - assert.ok(!toml.includes('.claude/agents/'), 'no .claude/agents/ references remain'); - assert.ok(!toml.includes('.claudeignore'), 'no .claudeignore references remain'); - - assert.ok(toml.includes('AGENTS.md'), 'AGENTS.md substituted for CLAUDE.md'); - assert.ok( - toml.includes('.codex/skills/') || toml.includes('.agents/skills/'), - 'skills path neutralized' - ); - - // Standalone "Claude" agent-name references replaced - assert.ok(!/\bClaude\b(?! Code| Opus| Sonnet| Haiku| native| based)/.test(toml), - 'standalone Claude agent-name references replaced'); - }); - - test('preserves Claude product/model names (Claude Code, Claude Opus) in TOML', () => { - writeAgentFixture(agentsSrc, 'gsd-executor', [ - 'This agent runs under Claude Code with the Claude Opus 4 model.', - 'Do not confuse with Claude Sonnet or Claude Haiku.', - ].join('\n')); - - installCodexConfig(targetDir, agentsSrc); - const toml = fs.readFileSync(path.join(targetDir, 'agents', 'gsd-executor.toml'), 'utf8'); - - assert.ok(toml.includes('Claude Code'), 'Claude Code product name preserved'); - assert.ok(toml.includes('Claude Opus'), 'Claude Opus model name preserved'); - }); -}); - }); -} - - -// ─── Codex config.toml [features] safety (#1202) ───────────────────────────── - -describe('codex features section safety', () => { - test('non-boolean keys under [features] are moved to top level', () => { - // Simulate the bug from #1202: model = "gpt-5.4" under [features] - // causes "invalid type: string, expected a boolean in features" - const configContent = `[features]\ncodex_hooks = true\n\nmodel = "gpt-5.4"\nmodel_reasoning_effort = "medium"\n\n[agents.gsd-executor]\ndescription = "test"\n`; - - const featuresMatch = configContent.match(/\[features\]\r?\n([\s\S]*?)(?=\n\[|$)/); - assert.ok(featuresMatch, 'features section found'); - - const featuresBody = featuresMatch[1]; - const nonBooleanKeys = featuresBody.split(/\r?\n/) - .filter(line => line.match(/^\s*\w+\s*=/) && !line.match(/=\s*(true|false)\s*(#.*)?$/)) - .map(line => line.trim()); - - assert.strictEqual(nonBooleanKeys.length, 2, 'should detect 2 non-boolean keys'); - assert.ok(nonBooleanKeys.includes('model = "gpt-5.4"'), 'detects model key'); - assert.ok(nonBooleanKeys.includes('model_reasoning_effort = "medium"'), 'detects model_reasoning_effort key'); - }); - - test('boolean keys under [features] are NOT flagged', () => { - const configContent = `[features]\ncodex_hooks = true\nmulti_agent = false\n`; - - const featuresMatch = configContent.match(/\[features\]\r?\n([\s\S]*?)(?=\n\[|$)/); - const featuresBody = featuresMatch[1]; - const nonBooleanKeys = featuresBody.split(/\r?\n/) - .filter(line => line.match(/^\s*\w+\s*=/) && !line.match(/=\s*(true|false)\s*(#.*)?$/)) - .map(line => line.trim()); - - assert.strictEqual(nonBooleanKeys.length, 0, 'no non-boolean keys in a clean config'); - }); -}); - -describe('Codex install hook configuration (e2e)', () => { - let tmpDir; - let codexHome; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-codex-e2e-')); - codexHome = path.join(tmpDir, 'codex-home'); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('Codex install copies hook file that is referenced in hooks.json (#2153)', () => { - // Regression test: Codex install writes gsd-check-update hook reference into - // hooks.json and must also copy the hook file to ~/$CODEX_HOME/hooks/ - runCodexInstall(codexHome); - - const configContent = readCodexConfig(codexHome); - const parsedConfig = parseTomlToObject(configContent); - assert.ok( - !parsedConfig.hooks || !Array.isArray(parsedConfig.hooks.SessionStart), - 'config.toml does not carry managed SessionStart hooks' - ); - const hooksJsonCommands = readHooksSessionStartCommands(codexHome); - assert.equal( - hooksJsonCommands.some((cmd) => cmd.includes('gsd-check-update')), - true, - 'hooks.json references gsd-check-update (.js on POSIX, .cmd on Windows)' - ); - // The hook file must physically exist at the referenced path - const hookFile = path.join(codexHome, 'hooks', 'gsd-check-update.js'); - assert.ok( - fs.existsSync(hookFile), - `gsd-check-update.js must exist at ${hookFile} — hooks.json references it (directly on POSIX, via .cmd shim on Windows) but file was not installed` - ); - }); - - test('fresh CODEX_HOME enables codex_hooks without draft root defaults', () => { - runCodexInstall(codexHome); - - const content = readCodexConfig(codexHome); - assert.ok(content.includes('[features]\nhooks = true\n'), 'writes codex_hooks feature'); - const parsed = parseTomlToObject(content); - assert.ok(!parsed.hooks || !Array.isArray(parsed.hooks.SessionStart), 'config.toml does not carry managed SessionStart hooks'); - // #3017 / #3426: on POSIX the handler command uses the absolute Node binary path - // "" "" - // On Windows (#3426) a .cmd shim is written instead; the command in hooks.json - // is the quoted .cmd path (no node runner prefix — cmd.exe executes .cmd natively). - const hooksJsonCommands = readHooksSessionStartCommands(codexHome); - const gsdCommands = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update')); - assert.strictEqual(gsdCommands.length, 1, 'writes one GSD update hook in hooks.json'); - if (process.platform === 'win32') { - // On Windows, the command is the .cmd shim path (quoted). - const expectedCmdPath = path.join(codexHome, 'hooks', 'gsd-check-update.cmd').replace(/\\/g, '/'); - assert.strictEqual(gsdCommands[0], JSON.stringify(expectedCmdPath), 'win32: handler command must be the .cmd shim path (#3426)'); - } else { - // On POSIX, the command is the node runner + .js hook path. - const expectedRunner = JSON.parse(resolveNodeRunner()); - const expectedHookPath = path.join(codexHome, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/'); - const expectedCommand = `"${expectedRunner}" "${expectedHookPath}"`; - assert.strictEqual(gsdCommands[0], expectedCommand, 'handler command must use absolute node runner pointing at gsd-check-update.js (#3017)'); - } - assert.strictEqual(countMatches(content, /^hooks = true$/gm), 1, 'writes one codex_hooks key'); - assertNoDraftRootKeys(content); - assertUsesOnlyEol(content, '\n'); - }); - - test('#2406: config.toml carries no config_file entries — standalone agents/*.toml under CODEX_HOME are the sole canonical source', () => { - runCodexInstall(codexHome); - - const content = readCodexConfig(codexHome); - // config.toml previously carried a `config_file = "/.toml"` - // line per role, pointing back at the standalone TOML Codex already - // auto-discovers under $CODEX_HOME/agents/ — a second, duplicate - // registration of the same role that produced one - // "Ignoring malformed agent role definition: duplicate agent role name" - // warning per agent. That line is gone entirely now. - const configFileLines = content.split(/\r?\n/).filter(l => l.startsWith('config_file = ')); - assert.deepStrictEqual(configFileLines, [], 'config.toml has zero config_file entries'); - - // The standalone per-agent TOMLs are still written under CODEX_HOME/agents/ - // and are what Codex auto-discovers. - const agentsDir = path.join(codexHome, 'agents'); - const tomlFiles = fs.existsSync(agentsDir) - ? fs.readdirSync(agentsDir).filter((f) => f.startsWith('gsd-') && f.endsWith('.toml')) - : []; - assert.ok(tomlFiles.length > 0, 'standalone gsd-*.toml files exist under CODEX_HOME/agents/'); - }); - - test('re-install repairs non-boolean keys trapped under [features] by previous install (#1379)', () => { - // Bug: a pre-#1346 install prepended [features] before bare top-level keys, - // trapping model= under [features]. Re-installing with the fix must detect - // and relocate those keys back to the top level so Codex can parse them. - writeCodexConfig(codexHome, [ - '[features]', - 'codex_hooks = true', - '', - 'model = "gpt-5.3-codex"', - 'model_reasoning_effort = "high"', - '', - '[projects."/Users/oltmannk/myproject"]', - 'trust_level = "trusted"', - '', - ].join('\n')); - - runCodexInstall(codexHome); - - const content = readCodexConfig(codexHome); - - // model= and model_reasoning_effort= must NOT be under [features] - const featuresIndex = content.indexOf('[features]'); - const modelIndex = content.indexOf('model = "gpt-5.3-codex"'); - const reasoningIndex = content.indexOf('model_reasoning_effort = "high"'); - assert.ok(modelIndex !== -1, 'model key is present'); - assert.ok(reasoningIndex !== -1, 'model_reasoning_effort key is present'); - assert.ok(modelIndex < featuresIndex, 'model= relocated before [features]'); - assert.ok(reasoningIndex < featuresIndex, 'model_reasoning_effort= relocated before [features]'); - - // [features] should only contain boolean keys - const featuresMatch = content.match(/\[features\]\r?\n([\s\S]*?)(?=\n\[|$)/); - assert.ok(featuresMatch, 'features section found'); - const featuresBody = featuresMatch[1]; - const nonBooleanKeys = featuresBody.split(/\r?\n/) - .filter(line => line.match(/^\s*\w+\s*=/) && !line.match(/=\s*(true|false)\s*(#.*)?$/)); - assert.strictEqual(nonBooleanKeys.length, 0, 'no non-boolean keys under [features]'); - - // User content preserved - assert.ok(content.includes('[projects."/Users/oltmannk/myproject"]'), 'preserves project section'); - assert.ok(content.includes('trust_level = "trusted"'), 'preserves project trust level'); - assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 1, 'one codex_hooks key'); - }); - - test('existing LF config without [features] gets one features block and preserves user content', () => { - writeCodexConfig(codexHome, [ - '# user comment', - '[model]', - 'name = "o3"', - '', - '[[hooks]]', - 'event = "SessionStart"', - 'command = "echo custom"', - '', - ].join('\n')); - - runCodexInstall(codexHome); - - const content = readCodexConfig(codexHome); - assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'creates one [features] section'); - assert.strictEqual(countMatches(content, /^hooks = true$/gm), 1, 'creates one codex_hooks key'); - assert.ok(content.includes('# user comment'), 'preserves user comment'); - assert.ok(content.includes('[model]\nname = "o3"'), 'preserves model section'); - assert.ok(content.includes('command = "echo custom"'), 'preserves custom hook'); - const hooksJsonCommands = readHooksSessionStartCommands(codexHome); - const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update')); - assert.strictEqual(gsdEntries.length, 1, 'adds one GSD update hook in hooks.json'); - assertNoDraftRootKeys(content); - }); - - test('bare top-level keys are NOT trapped under [features] (#1202)', () => { - // Real-world config: model= and model_reasoning_effort= at root level, - // followed by [projects] section. GSD must not prepend [features] before - // these keys, which would make Codex reject them as "expected a boolean". - writeCodexConfig(codexHome, [ - 'model = "gpt-5.4"', - 'model_reasoning_effort = "high"', - '', - '[projects."/home/user/myproject"]', - 'trust_level = "trusted"', - '', - ].join('\n')); - - runCodexInstall(codexHome); - - const content = readCodexConfig(codexHome); - - // [features] must come AFTER bare top-level keys - const featuresIndex = content.indexOf('[features]'); - const modelIndex = content.indexOf('model = "gpt-5.4"'); - const reasoningIndex = content.indexOf('model_reasoning_effort = "high"'); - assert.ok(modelIndex < featuresIndex, 'model= stays before [features]'); - assert.ok(reasoningIndex < featuresIndex, 'model_reasoning_effort= stays before [features]'); - - // [features] should only contain boolean keys - const featuresMatch = content.match(/\[features\]\r?\n([\s\S]*?)(?=\n\[|$)/); - assert.ok(featuresMatch, 'features section found'); - const featuresBody = featuresMatch[1]; - const nonBooleanKeys = featuresBody.split(/\r?\n/) - .filter(line => line.match(/^\s*\w+\s*=/) && !line.match(/=\s*(true|false)\s*(#.*)?$/)); - assert.strictEqual(nonBooleanKeys.length, 0, 'no non-boolean keys under [features]'); - - // User content preserved - assert.ok(content.includes('[projects."/home/user/myproject"]'), 'preserves project section'); - assert.ok(content.includes('trust_level = "trusted"'), 'preserves project trust level'); - }); - - test('existing CRLF config without [features] preserves CRLF and adds codex_hooks', () => { - writeCodexConfig(codexHome, '# user comment\r\n[model]\r\nname = "o3"\r\n'); - - runCodexInstall(codexHome); - - const content = readCodexConfig(codexHome); - assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'creates one [features] section'); - assert.strictEqual(countMatches(content, /^hooks = true$/gm), 1, 'creates one codex_hooks key'); - assert.ok(content.includes('# user comment'), 'preserves user comment'); - assert.ok(content.includes('[model]\r\nname = "o3"'), 'preserves model section'); - // [features] should be inserted between top-level lines and [model], not prepended - const featuresIndex = content.indexOf('[features]'); - const modelIndex = content.indexOf('[model]'); - assert.ok(featuresIndex < modelIndex, '[features] comes before [model]'); - assertUsesOnlyEol(content, '\r\n'); - assertNoDraftRootKeys(content); - }); - - test('existing CRLF [features] comment-only table gets codex_hooks without losing adjacent text', () => { - writeCodexConfig(codexHome, [ - '# user comment', - '[features]', - '# keep me', - '', - '[model]', - 'name = "o3"', - '', - ].join('\r\n')); - - runCodexInstall(codexHome); - - const content = readCodexConfig(codexHome); - assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'keeps one [features] section'); - assert.strictEqual(countMatches(content, /^hooks = true$/gm), 1, 'adds one codex_hooks key'); - assert.ok(content.includes('[features]\r\n# keep me\r\n\r\nhooks = true\r\n'), 'adds codex_hooks within comment-only table'); - assert.ok(content.includes('[model]\r\nname = "o3"\r\n'), 'preserves following table'); - assertUsesOnlyEol(content, '\r\n'); - assertNoDraftRootKeys(content); - }); - - test('existing [features] with trailing comment gets one codex_hooks without a second table', () => { - writeCodexConfig(codexHome, [ - '[features] # keep comment', - 'other_feature = true', - '', - '[model]', - 'name = "o3"', - '', - ].join('\n')); - - runCodexInstall(codexHome); - - const content = readCodexConfig(codexHome); - assert.strictEqual(countMatches(content, /^\s*\[features\](?:\s*#.*)?$/gm), 1, 'keeps one commented [features] header'); - assert.strictEqual(countMatches(content, /^hooks = true$/gm), 1, 'adds one codex_hooks key'); - assert.ok(content.includes('[features] # keep comment\nother_feature = true'), 'preserves commented features table'); - assert.ok(content.indexOf('hooks = true') > content.indexOf('[features] # keep comment'), 'adds codex_hooks within existing features table'); - assert.ok(content.indexOf('hooks = true') < content.indexOf('[model]'), 'does not create a second features table before model'); - assertNoDraftRootKeys(content); - }); - - test('existing [features] at EOF without trailing newline is updated in place', () => { - writeCodexConfig(codexHome, '[model]\nname = "o3"\n\n[features]'); - - runCodexInstall(codexHome); - - const content = readCodexConfig(codexHome); - assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'keeps one [features] section'); - assert.strictEqual(countMatches(content, /^hooks = true$/gm), 1, 'adds one codex_hooks key'); - assert.ok(content.indexOf('hooks = true') > content.indexOf('[features]'), 'adds codex_hooks after the existing EOF features header'); - // In this EOF-without-trailing-newline edge case, the pre-existing - // [features] header has no blank-line boundary to close it, so the - // appended GSD marker/ownership comment textually falls *inside* what - // reads as the [features] section body, and `hooks = true` is inserted - // at the end of that body — after the marker, not before it. That - // ordering is unrelated to #2406 (verified unchanged against - // origin/next's install.js) and #2406 removed the [agents.] role - // tables that used to anchor this assertion, so anchor on the bare - // [agents] dispatch-tuning table instead — codex_hooks always lands - // before it. - assert.ok(content.indexOf('hooks = true') < content.indexOf('[agents]'), 'keeps codex_hooks before the [agents] dispatch-tuning table'); - assertNoDraftRootKeys(content); - }); - - test('existing empty [features] and codex_hooks = false are normalized and remain idempotent', () => { - writeCodexConfig(codexHome, [ - '[features]', - 'codex_hooks = false', - 'other_feature = true', - '', - '[[hooks]]', - 'event = "SessionStart"', - 'command = "echo custom"', - '', - ].join('\n')); - - runCodexInstall(codexHome); - runCodexInstall(codexHome); - runCodexInstall(codexHome); - - const content = readCodexConfig(codexHome); - assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'keeps one [features] section'); - assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 1, 'normalizes to one codex_hooks = true'); - assert.ok(!content.includes('codex_hooks = false'), 'removes false codex_hooks value'); - assert.ok(content.includes('other_feature = true'), 'preserves other feature keys'); - assert.ok(content.includes('command = "echo custom"'), 'preserves custom hook'); - const hooksJsonCommands = readHooksSessionStartCommands(codexHome); - const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update')); - assert.strictEqual(gsdEntries.length, 1, 'does not duplicate GSD update hook in hooks.json'); - assertNoDraftRootKeys(content); - }); - - test('quoted codex_hooks keys inside [features] are normalized without adding a bare duplicate', () => { - writeCodexConfig(codexHome, [ - '[features]', - '"codex_hooks" = false', - 'other_feature = true', - '', - ].join('\n')); - - runCodexInstall(codexHome); - runCodexInstall(codexHome); - - const content = readCodexConfig(codexHome); - assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'keeps one [features] section'); - assert.strictEqual(countMatches(content, /^"codex_hooks" = true$/gm), 1, 'normalizes the quoted key to true'); - assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 0, 'does not append a bare duplicate codex_hooks key'); - assert.ok(content.includes('other_feature = true'), 'preserves other feature keys'); - assertNoDraftRootKeys(content); - }); - - test('quoted [features] headers are recognized as the existing features table', () => { - writeCodexConfig(codexHome, [ - '["features"]', - '"codex_hooks" = false', - 'other_feature = true', - '', - '[model]', - 'name = "o3"', - '', - ].join('\n')); - - runCodexInstall(codexHome); - runCodexInstall(codexHome); - - const content = readCodexConfig(codexHome); - assert.strictEqual(countMatches(content, /^\[(?:"features"|'features'|features)\]\s*$/gm), 1, 'keeps one features table'); - assert.strictEqual(countMatches(content, /^"codex_hooks" = true$/gm), 1, 'normalizes the quoted codex_hooks key to true'); - assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 0, 'does not prepend a second bare features table'); - assert.ok(content.includes('other_feature = true'), 'preserves existing feature keys'); - const hooksJsonCommands = readHooksSessionStartCommands(codexHome); - const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update')); - assert.strictEqual(gsdEntries.length, 1, 'keeps one GSD update hook in hooks.json'); - assertNoDraftRootKeys(content); - }); - - test('quoted table headers containing # are parsed without treating # as a comment start', () => { - writeCodexConfig(codexHome, [ - '[features."a#b"]', - 'enabled = true', - '', - '[model]', - 'name = "o3"', - '', - ].join('\n')); - - runCodexInstall(codexHome); - runCodexInstall(codexHome); - - const content = readCodexConfig(codexHome); - assert.ok(content.includes('[features."a#b"]\nenabled = true'), 'preserves the quoted nested features table'); - assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'adds one real top-level features table'); - assert.strictEqual(countMatches(content, /^hooks = true$/gm), 1, 'adds one codex_hooks key'); - const hooksJsonCommands = readHooksSessionStartCommands(codexHome); - const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update')); - assert.strictEqual(gsdEntries.length, 1, 'remains idempotent for the GSD hook block in hooks.json'); - assertNoDraftRootKeys(content); - }); - - test('existing dotted features config stays dotted and does not grow a [features] table', () => { - writeCodexConfig(codexHome, [ - 'features.other_feature = true', - '', - '[model]', - 'name = "o3"', - '', - ].join('\n')); - - runCodexInstall(codexHome); - runCodexInstall(codexHome); - - const content = readCodexConfig(codexHome); - assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 0, 'does not add a [features] table'); - assert.strictEqual(countMatches(content, /^features\.hooks = true$/gm), 1, 'adds one dotted codex_hooks key'); - assert.ok(content.includes('features.other_feature = true'), 'preserves existing dotted features key'); - const hooksJsonCommands = readHooksSessionStartCommands(codexHome); - const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update')); - assert.strictEqual(gsdEntries.length, 1, 'adds one GSD update hook for dotted codex_hooks and remains idempotent'); - assertNoDraftRootKeys(content); - }); - - test('root inline-table features assignments are left untouched without appending invalid dotted keys or hooks', () => { - writeCodexConfig(codexHome, [ - 'features = { other_feature = true }', - '', - '[model]', - 'name = "o3"', - '', - ].join('\n')); - - runCodexInstall(codexHome); - runCodexInstall(codexHome); - - const content = readCodexConfig(codexHome); - assert.ok(content.includes('features = { other_feature = true }'), 'preserves the root inline-table assignment'); - assert.strictEqual(countMatches(content, /^features\.codex_hooks = true$/gm), 0, 'does not append an invalid dotted codex_hooks key'); - assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 0, 'does not prepend a features table'); - assert.strictEqual(countMatches(content, /gsd-check-update\.js/g), 0, 'does not add the GSD hook block when codex_hooks cannot be enabled safely'); - // #2406: config.toml no longer carries an [agents.] role table — - // it still installs the managed [agents] dispatch-tuning block. - assert.ok(content.includes(GSD_CODEX_MARKER), 'still installs the managed GSD block'); - assert.ok(!content.includes('[agents.gsd-executor]'), 'no agent role table (canonical source is the standalone TOML)'); - assertNoDraftRootKeys(content); - }); - - test('root scalar features assignments are left untouched without appending invalid dotted keys or hooks', () => { - writeCodexConfig(codexHome, [ - 'features = "disabled"', - '', - '[model]', - 'name = "o3"', - '', - ].join('\n')); - - runCodexInstall(codexHome); - runCodexInstall(codexHome); - - const content = readCodexConfig(codexHome); - assert.ok(content.includes('features = "disabled"'), 'preserves the root scalar assignment'); - assert.strictEqual(countMatches(content, /^features\.codex_hooks = true$/gm), 0, 'does not append an invalid dotted codex_hooks key'); - assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 0, 'does not prepend a features table'); - assert.strictEqual(countMatches(content, /gsd-check-update\.js/g), 0, 'does not add the GSD hook block when codex_hooks cannot be enabled safely'); - // #2406: config.toml no longer carries an [agents.] role table — - // it still installs the managed [agents] dispatch-tuning block. - assert.ok(content.includes(GSD_CODEX_MARKER), 'still installs the managed GSD block'); - assert.ok(!content.includes('[agents.gsd-executor]'), 'no agent role table (canonical source is the standalone TOML)'); - assertNoDraftRootKeys(content); - }); - - test('quoted dotted codex_hooks keys stay dotted and are normalized without duplication', () => { - writeCodexConfig(codexHome, [ - 'features."codex_hooks" = false', - 'features.other_feature = true', - '', - '[model]', - 'name = "o3"', - '', - ].join('\n')); - - runCodexInstall(codexHome); - runCodexInstall(codexHome); - - const content = readCodexConfig(codexHome); - assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 0, 'does not add a [features] table'); - assert.strictEqual(countMatches(content, /^features\."codex_hooks" = true$/gm), 1, 'normalizes the quoted dotted key to true'); - assert.strictEqual(countMatches(content, /^features\.codex_hooks = true$/gm), 0, 'does not append a bare dotted duplicate'); - assert.ok(content.includes('features.other_feature = true'), 'preserves other dotted features keys'); - const hooksJsonCommands = readHooksSessionStartCommands(codexHome); - const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update')); - assert.strictEqual(gsdEntries.length, 1, 'adds one GSD update hook for quoted dotted codex_hooks and remains idempotent'); - assertNoDraftRootKeys(content); - }); - - test('multiline dotted features assignments insert codex_hooks after the full assignment block', () => { - writeCodexConfig(codexHome, [ - 'features.notes = """', - 'keep-me', - '"""', - '', - '[model]', - 'name = "o3"', - '', - ].join('\n')); - - runCodexInstall(codexHome); - runCodexInstall(codexHome); - - const content = readCodexConfig(codexHome); - assert.ok(content.includes('features.notes = """\nkeep-me\n"""'), 'preserves the multiline dotted assignment'); - assert.strictEqual(countMatches(content, /^features\.hooks = true$/gm), 1, 'adds one dotted codex_hooks key'); - assert.ok(content.indexOf('features.hooks = true') > content.indexOf('"""'), 'inserts codex_hooks after the multiline assignment closes'); - assert.ok(content.indexOf('features.hooks = true') < content.indexOf('[model]'), 'inserts codex_hooks before the next table'); - assertNoDraftRootKeys(content); - }); - - test('existing empty [features] table is populated with one codex_hooks key', () => { - writeCodexConfig(codexHome, '[features]\r\n\r\n[model]\r\nname = "o3"\r\n'); - - runCodexInstall(codexHome); - - const content = readCodexConfig(codexHome); - assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'keeps one [features] section'); - assert.strictEqual(countMatches(content, /^hooks = true$/gm), 1, 'adds one codex_hooks key'); - assert.ok(content.includes('[features]\r\n\r\nhooks = true\r\n'), 'adds codex_hooks to empty table'); - assertUsesOnlyEol(content, '\r\n'); - assertNoDraftRootKeys(content); - }); - - test('multiline strings inside [features] do not create fake tables or fake codex_hooks matches', () => { - writeCodexConfig(codexHome, [ - '[features]', - 'notes = \'\'\'', - '[model]', - 'codex_hooks = false', - '\'\'\'', - 'other_feature = true', - '', - '[[hooks]]', - 'event = "AfterCommand"', - 'command = "echo custom-after-command"', - '', - ].join('\n')); - - runCodexInstall(codexHome); - - const content = readCodexConfig(codexHome); - assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'keeps one [features] section'); - assert.strictEqual(countMatches(content, /^hooks = true$/gm), 1, 'adds a real codex_hooks key once'); - assert.ok(content.includes('notes = \'\'\'\n[model]\ncodex_hooks = false\n\'\'\''), 'preserves multiline string content'); - assert.strictEqual(countMatches(content, /^codex_hooks = false$/gm), 1, 'does not rewrite codex_hooks text inside multiline string'); - assert.ok(content.indexOf('hooks = true') > content.indexOf('other_feature = true'), 'does not stop the features section at multiline string content'); - // Parse structurally — verify codex_hooks and migrated AfterCommand hook via parsed object - const parsed = parseTomlToObject(content); - assert.equal(parsed.features?.hooks, true, 'writes a real hooks boolean key (#3566)'); - assert.ok(Array.isArray(parsed.hooks?.AfterCommand), 'AfterCommand flat [[hooks]] migrated to namespaced AoT'); - const afterCmds = parsed.hooks.AfterCommand.flatMap((entry) => - Array.isArray(entry.hooks) ? entry.hooks.map((h) => h.command).filter(Boolean) : [] - ); - assert.ok(afterCmds.includes('echo custom-after-command'), 'preserves AfterCommand user hook command'); - assertNoDraftRootKeys(content); - }); - - test('non-boolean codex_hooks assignments are normalized to true without duplication', () => { - writeCodexConfig(codexHome, [ - '[features]', - 'codex_hooks = "sometimes"', - 'other_feature = true', - '', - '[model]', - 'name = "o3"', - '', - ].join('\n')); - - runCodexInstall(codexHome); - - const content = readCodexConfig(codexHome); - assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'keeps one [features] section'); - assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 1, 'normalizes to one true value'); - assert.ok(!content.includes('codex_hooks = "sometimes"'), 'removes non-boolean value'); - assert.ok(content.includes('other_feature = true'), 'preserves other feature keys'); - assertNoDraftRootKeys(content); - }); - - test('multiline basic-string codex_hooks assignments are fully normalized without leaving trailing lines behind', () => { - writeCodexConfig(codexHome, [ - '[features]', - 'codex_hooks = """', - 'multiline-basic-sentinel', - 'still-in-string', - '"""', - 'other_feature = true', - '', - '[model]', - 'name = "o3"', - '', - ].join('\n')); - - runCodexInstall(codexHome); - runCodexInstall(codexHome); - - const content = readCodexConfig(codexHome); - assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 1, 'replaces the multiline basic-string assignment with one true value'); - assert.ok(!content.includes('multiline-basic-sentinel'), 'removes multiline basic-string continuation lines'); - assert.ok(content.includes('other_feature = true'), 'preserves following feature keys'); - const hooksJsonCommands = readHooksSessionStartCommands(codexHome); - const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update')); - assert.strictEqual(gsdEntries.length, 1, 'remains idempotent for the GSD hook block in hooks.json'); - assertNoDraftRootKeys(content); - }); - - test('multiline literal-string codex_hooks assignments are fully normalized without leaving trailing lines behind', () => { - writeCodexConfig(codexHome, [ - '[features]', - 'codex_hooks = \'\'\'', - 'multiline-literal-sentinel', - 'still-in-literal', - '\'\'\'', - 'other_feature = true', - '', - '[model]', - 'name = "o3"', - '', - ].join('\n')); - - runCodexInstall(codexHome); - runCodexInstall(codexHome); - - const content = readCodexConfig(codexHome); - assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 1, 'replaces the multiline literal-string assignment with one true value'); - assert.ok(!content.includes('multiline-literal-sentinel'), 'removes multiline literal-string continuation lines'); - assert.ok(content.includes('other_feature = true'), 'preserves following feature keys'); - const hooksJsonCommands = readHooksSessionStartCommands(codexHome); - const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update')); - assert.strictEqual(gsdEntries.length, 1, 'remains idempotent for the GSD hook block in hooks.json'); - assertNoDraftRootKeys(content); - }); - - test('multiline array codex_hooks assignments are fully normalized without leaving trailing lines behind', () => { - writeCodexConfig(codexHome, [ - '[features]', - 'codex_hooks = [', - ' "array-sentinel-1",', - ' "array-sentinel-2",', - ']', - 'other_feature = true', - '', - '[model]', - 'name = "o3"', - '', - ].join('\n')); - - runCodexInstall(codexHome); - runCodexInstall(codexHome); - - const content = readCodexConfig(codexHome); - assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 1, 'replaces the multiline array assignment with one true value'); - assert.ok(!content.includes('array-sentinel-1'), 'removes multiline array continuation lines'); - assert.ok(!content.includes('array-sentinel-2'), 'removes multiline array continuation lines'); - assert.ok(content.includes('other_feature = true'), 'preserves following feature keys'); - const hooksJsonCommands = readHooksSessionStartCommands(codexHome); - const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update')); - assert.strictEqual(gsdEntries.length, 1, 'remains idempotent for the GSD hook block in hooks.json'); - assertNoDraftRootKeys(content); - }); - - test('triple-quoted codex_hooks values keep inline comments when normalized', () => { - writeCodexConfig(codexHome, [ - '[features]', - 'codex_hooks = """sometimes""" # keep me', - 'other_feature = true', - '', - '[model]', - 'name = "o3"', - '', - ].join('\n')); - - runCodexInstall(codexHome); - - const content = readCodexConfig(codexHome); - assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'keeps one [features] section'); - assert.strictEqual(countMatches(content, /^codex_hooks = true # keep me$/gm), 1, 'normalizes to true and preserves inline comment'); - assert.ok(!content.includes('"""sometimes"""'), 'removes the old triple-quoted value'); - assert.ok(content.includes('other_feature = true'), 'preserves other feature keys'); - assertNoDraftRootKeys(content); - }); - - test('existing CRLF codex_hooks = true stays single and preserves non-GSD hooks', () => { - writeCodexConfig(codexHome, [ - '[features]', - 'codex_hooks = true', - 'other_feature = true', - '', - '[[hooks]]', - 'event = "AfterCommand"', - 'command = "echo custom-after-command"', - '', - ].join('\r\n')); - - runCodexInstall(codexHome); - runCodexInstall(codexHome); - - const content = readCodexConfig(codexHome); - assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'keeps one [features] section'); - assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 1, 'keeps one codex_hooks = true'); - assert.ok(content.includes('other_feature = true'), 'preserves other feature keys'); - assert.strictEqual(countMatches(content, /echo custom-after-command/g), 1, 'preserves non-GSD hook exactly once'); - const hooksJsonCommands = readHooksSessionStartCommands(codexHome); - const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update')); - assert.strictEqual(gsdEntries.length, 1, 'keeps one GSD update hook in hooks.json'); - assertUsesOnlyEol(content, '\r\n'); - assertNoDraftRootKeys(content); - }); - - test('codex_hooks = true with an inline comment is treated as enabled for hook installation', () => { - writeCodexConfig(codexHome, [ - '[features]', - 'codex_hooks = true # keep me', - 'other_feature = true', - '', - '[model]', - 'name = "o3"', - '', - ].join('\n')); - - runCodexInstall(codexHome); - runCodexInstall(codexHome); - - const content = readCodexConfig(codexHome); - assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'keeps one [features] section'); - assert.strictEqual(countMatches(content, /^codex_hooks = true # keep me$/gm), 1, 'preserves the commented true value'); - assert.ok(content.includes('other_feature = true'), 'preserves other feature keys'); - const hooksJsonCommands = readHooksSessionStartCommands(codexHome); - const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update')); - assert.strictEqual(gsdEntries.length, 1, 'adds the GSD update hook once in hooks.json'); - assertNoDraftRootKeys(content); - }); - - test('mixed-EOL configs use the first newline style for inserted Codex content', () => { - writeCodexConfig(codexHome, '# first line wins\n[model]\r\nname = "o3"\r\n'); - - runCodexInstall(codexHome); - runCodexInstall(codexHome); - - const content = readCodexConfig(codexHome); - // [features] is inserted after top-level lines, before [model] — not prepended - assert.ok(content.includes('# first line wins\n\n[features]\nhooks = true\n'), 'inserts features after top-level lines using first newline style'); - assert.ok(content.includes(`# GSD Agent Configuration — managed by gsd-core installer\n`), 'writes the managed agent block using the first newline style'); - // Structural check: managed SessionStart hooks live in hooks.json. - const parsedMixed = parseTomlToObject(content); - assert.ok(!parsedMixed.hooks || !Array.isArray(parsedMixed.hooks.SessionStart), 'does not write managed SessionStart hooks to config.toml'); - const hooksJsonCommands = readHooksSessionStartCommands(codexHome); - const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update')); - assert.strictEqual(gsdEntries.length, 1, 'writes one managed SessionStart hook to hooks.json'); - assert.ok(content.includes('[model]\r\nname = "o3"'), 'preserves the existing CRLF model lines'); - assert.strictEqual(countMatches(content, /^hooks = true$/gm), 1, 'remains idempotent on repeated installs'); - assertNoDraftRootKeys(content); - }); -}); - - -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/issue-2695-codex-hook-set.test.cjs — consolidation epic #1969 (H3 W4 #3336) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:bug-2695-codex-hook-set (consolidation epic #1969 H3 W4 #3336)", () => { -// Regression tests for #2695 — Codex native updates omit the update-hook worker -// and the managed-hooks registry. -// -// The Codex install branch in bin/install.js used to allowlist only two of the -// four hook files the shipped build emitted at the time (gsd-check-update.js + -// gsd-context-monitor.js — the latter permanently removed by #2586, see below), -// and gated the entire branch on !isMinimalMode so the -// `core` profile installed none of them. The parent SessionStart hook spawn()s -// the worker, which require()s the registry — so Codex was wired to a dependency -// chain the same installer never delivered. -// -// These tests drive the real installer (bin/install.js) behaviorally into an -// isolated temp config dir and assert the complete three-file set is delivered -// for both profiles, the registry is byte-for-byte, the version stamps resolve -// to the installed package version, and unrelated user files are preserved. -// -// #2586 reduced the set back to three: gsd-context-monitor.js read a Claude-only -// statusline bridge file Codex never writes, so it was a guaranteed silent no-op -// on every Codex hook event and was dropped from CODEX_HOOKS_TO_COPY for good. -// -// Verified non-duplicate: the pre-existing 'Codex install hook configuration -// (e2e)' suite above only asserts gsd-check-update.js delivery/wiring — it never -// asserts on gsd-check-update-worker.js, managed-hooks-registry.cjs, the -// core/full profile matrix, upgrade-refresh, byte-for-byte registry copy, -// idempotency of the three-file set, user-file preservation, or the -// core-profile negative-space (no agent files) — all genuinely distinct -// assertions this fold adds. - -'use strict'; - -const { test, describe, before } = 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 { runNode } = require('./helpers/process-seam.cjs'); -const { throwIfFailed } = require('./helpers/git-fixture.cjs'); - -const { cleanup } = require('./helpers.cjs'); -const { - INSTALL_SCRIPT, - BUILD_SCRIPT, - HOOKS_DIST, - installerEnv, -} = require('./helpers/install-shared.cjs'); - -const PKG_VERSION = require('../package.json').version; - -// #3145: class-norm timeouts, not per-suite values — see helpers/timeouts.cjs. -const { - BUILD_TIMEOUT_MS: BUILD_HOOKS_TIMEOUT_MS, - INSTALL_TIMEOUT_MS, -} = require('./helpers/timeouts.cjs'); - -// The three-file hook set the Codex surface must deliver together (#2695). -// gsd-context-monitor.js was removed from this set by #2586: it read a -// Claude-only statusline bridge file Codex never writes, so it was a -// guaranteed silent no-op on every Codex hook event. -const CODEX_HOOK_FILES = [ - 'gsd-check-update.js', - 'gsd-check-update-worker.js', - 'managed-hooks-registry.cjs', -]; - -// Build hooks/dist before any install runs (the installer copies from there). -before(() => { - const r = runNode([BUILD_SCRIPT], { timeoutMs: BUILD_HOOKS_TIMEOUT_MS }); - throwIfFailed(r, `node ${BUILD_SCRIPT}`); -}); - -function hooksDirOf(configDir) { - return path.join(configDir, 'hooks'); -} - -/** Run the Codex installer into an isolated temp config dir. */ -function runCodexInstall({ profile, preseed }) { - const configDir = fs.mkdtempSync(path.join(os.tmpdir(), `gsd-2695-${profile}-`)); - if (preseed) { - const hooksDest = hooksDirOf(configDir); - fs.mkdirSync(hooksDest, { recursive: true }); - for (const [name, body] of Object.entries(preseed)) { - fs.writeFileSync(path.join(hooksDest, name), body); - } - } - // Sandbox HOME/USERPROFILE to configDir: Codex's skills-kind `home: ".agents"` - // override resolves via os.homedir(); sandboxing keeps the spawn self-contained - // (mirrors tests/install-minimal-hooks.test.cjs Codex downgrade test). - const result = runNode( - [INSTALL_SCRIPT, '--codex', '--global', '--config-dir', configDir, `--profile=${profile}`], - { env: installerEnv({ HOME: configDir, USERPROFILE: configDir }), timeoutMs: INSTALL_TIMEOUT_MS }, - ); - return { configDir, result }; -} - -// Older-version stamp used to pre-seed an "upgrade" scenario. -const OLDER_VERSION = '1.7.0'; - -describe('#2695: fresh Codex installs deliver the complete three-file hook set', () => { - for (const profile of ['core', 'full']) { - test(`fresh --profile=${profile} installs all three hook files`, (t) => { - const { configDir, result } = runCodexInstall({ profile }); - t.after(() => cleanup(configDir)); - - const hooksDir = hooksDirOf(configDir); - for (const file of CODEX_HOOK_FILES) { - assert.ok( - fs.existsSync(path.join(hooksDir, file)), - `expected ${file} under /hooks for --profile=${profile}\n` + - `installer stdout: ${result.stdout}\ninstaller stderr: ${result.stderr}`, - ); - } - }); - } -}); - -describe('#2695: Codex upgrades refresh all three hook files to the current version', () => { - // Pre-seed all three files stamped at OLDER_VERSION so an upgrade must overwrite them. - function olderSeed() { - const seed = {}; - for (const name of CODEX_HOOK_FILES) { - // Registry carries no version token; seed it with a stale sentinel body. - if (name.endsWith('.cjs')) { - seed[name] = `// stale registry ${OLDER_VERSION}\nmodule.exports = {};\n`; - } else { - seed[name] = `// gsd-hook-version: ${OLDER_VERSION}\n// stale\n`; - } - } - return seed; - } - - for (const profile of ['core', 'full']) { - test(`--profile=${profile} upgrade refreshes all three hook files`, (t) => { - const { configDir, result } = runCodexInstall({ profile, preseed: olderSeed() }); - t.after(() => cleanup(configDir)); - - const hooksDir = hooksDirOf(configDir); - // All three must now carry the current version stamp where one exists, and - // the registry must no longer be the stale sentinel. - for (const name of CODEX_HOOK_FILES) { - const dest = path.join(hooksDir, name); - assert.ok( - fs.existsSync(dest), - `expected refreshed ${name} for --profile=${profile}\n` + - `installer stdout: ${result.stdout}\ninstaller stderr: ${result.stderr}`, - ); - } - // The registry must be REFRESHED on upgrade, not merely present: assert it no - // longer carries the stale sentinel and now matches the shipped dist byte-for-byte - // (the raw-copy fallback must overwrite an existing dest, not skip it). - const registryDest = path.join(hooksDir, 'managed-hooks-registry.cjs'); - const registryBytes = fs.readFileSync(registryDest, 'utf8'); - assert.ok( - !registryBytes.includes(`stale registry ${OLDER_VERSION}`), - `registry must be refreshed on upgrade for --profile=${profile} (still carries the stale sentinel)`, - ); - assert.deepStrictEqual( - fs.readFileSync(registryDest), - fs.readFileSync(path.join(HOOKS_DIST, 'managed-hooks-registry.cjs')), - `refreshed registry must match hooks/dist byte-for-byte for --profile=${profile}`, - ); - // Version stamps resolved (acceptance #2/#3). - const workerStamp = readHookVersionLine(path.join(hooksDir, 'gsd-check-update-worker.js')); - assert.strictEqual( - workerStamp, PKG_VERSION, - `worker gsd-hook-version stamp must be the installed package version (${PKG_VERSION}), ` + - `got "${workerStamp}" for --profile=${profile}`, - ); - const parentStamp = readHookVersionLine(path.join(hooksDir, 'gsd-check-update.js')); - assert.strictEqual( - parentStamp, PKG_VERSION, - `parent gsd-check-update stamp must be the installed package version (${PKG_VERSION}), ` + - `got "${parentStamp}" for --profile=${profile}`, - ); - }); - } -}); - -describe('#2695: managed-hooks-registry.cjs is copied byte-for-byte', () => { - for (const profile of ['core', 'full']) { - test(`--profile=${profile} registry matches hooks/dist byte-for-byte`, (t) => { - const { configDir, result } = runCodexInstall({ profile }); - t.after(() => cleanup(configDir)); - - const dest = path.join(hooksDirOf(configDir), 'managed-hooks-registry.cjs'); - assert.ok(fs.existsSync(dest), `registry missing for --profile=${profile}\nstdout: ${result.stdout}`); - const distBytes = fs.readFileSync(path.join(HOOKS_DIST, 'managed-hooks-registry.cjs')); - const destBytes = fs.readFileSync(dest); - assert.deepStrictEqual( - destBytes, distBytes, - `managed-hooks-registry.cjs must be copied byte-for-byte (no version/path transform) for --profile=${profile}`, - ); - }); - } -}); - -describe('#2695: worker hook-version stamp is a literal install-time value', () => { - test('the stamp is the literal package version, never a placeholder or a runtime lookup', (t) => { - const { configDir } = runCodexInstall({ profile: 'full' }); - t.after(() => cleanup(configDir)); - - const workerPath = path.join(hooksDirOf(configDir), 'gsd-check-update-worker.js'); - const content = fs.readFileSync(workerPath, 'utf8'); - // The placeholder must have been replaced — a leftover {{GSD_VERSION}} is the bug shape. - assert.ok( - !content.includes('{{GSD_VERSION}}'), - 'worker still carries an unresolved {{GSD_VERSION}} placeholder — stamping did not run', - ); - // And the resolved value must be the literal version, present on the version-comment line. - const stamp = readHookVersionLine(workerPath); - assert.strictEqual(stamp, PKG_VERSION, `worker stamp must equal package.json version, got "${stamp}"`); - }); -}); - -describe('#2695: unrelated user-owned hook files are preserved', () => { - for (const profile of ['core', 'full']) { - test(`--profile=${profile} leaves a pre-existing user hook untouched`, (t) => { - const userOwned = 'my-custom-hook.js'; - const userBody = '// user-owned hook — do not touch\nconsole.log("mine");\n'; - const { configDir, result } = runCodexInstall({ profile, preseed: { [userOwned]: userBody } }); - t.after(() => cleanup(configDir)); - - const dest = path.join(hooksDirOf(configDir), userOwned); - assert.ok(fs.existsSync(dest), `user-owned ${userOwned} must be preserved for --profile=${profile}\nstdout: ${result.stdout}`); - assert.strictEqual( - fs.readFileSync(dest, 'utf8'), userBody, - `user-owned ${userOwned} bytes must be unchanged for --profile=${profile}`, - ); - }); - } -}); - -describe('#2695: re-running the installer is idempotent for the three-file set', () => { - test('a second full install leaves all three files present and correctly stamped', (t) => { - const first = runCodexInstall({ profile: 'full' }); - t.after(() => cleanup(first.configDir)); - // Second run into the SAME config dir. - const result2 = runNode( - [INSTALL_SCRIPT, '--codex', '--global', '--config-dir', first.configDir, '--profile=full'], - { env: installerEnv({ HOME: first.configDir, USERPROFILE: first.configDir }), timeoutMs: INSTALL_TIMEOUT_MS }, - ); - assert.ok(result2.stdout || result2.stderr); - - const hooksDir = hooksDirOf(first.configDir); - for (const name of CODEX_HOOK_FILES) { - assert.ok(fs.existsSync(path.join(hooksDir, name)), `${name} must survive a second install`); - } - assert.strictEqual( - readHookVersionLine(path.join(hooksDir, 'gsd-check-update-worker.js')), - PKG_VERSION, - 'worker stamp must remain correct after a second install', - ); - }); -}); - -describe('#2695: the core profile enables the hook feature and wires SessionStart (intended)', () => { - // For the update-check/context-monitor hooks to actually fire, Codex needs both - // the feature flag in config.toml AND the hooks.json routing — copying inert - // files alone would leave `core` with scripts Codex never invokes. Entering the - // codex-toml branch for `core` (the #2695 gate change) synthesizes `[features] - // hooks = true` via ensureCodexHooksFeature, writes config.toml, and registers - // the hooks. This is the intended behavior of the fix, not a side effect — these - // assertions pin it so a future re-gating cannot silently regress it. - test('--profile=core writes config.toml enabling the hooks feature', (t) => { - const { configDir } = runCodexInstall({ profile: 'core' }); - t.after(() => cleanup(configDir)); - - const configPath = path.join(configDir, 'config.toml'); - assert.ok(fs.existsSync(configPath), 'core must write config.toml so the hooks feature is enabled'); - const config = fs.readFileSync(configPath, 'utf8'); - assert.ok(/^\s*hooks\s*=\s*true\s*$/m.test(config), 'config.toml must enable hooks = true for core'); - }); - - test('--profile=core wires the SessionStart update-check hook in hooks.json', (t) => { - const { configDir } = runCodexInstall({ profile: 'core' }); - t.after(() => cleanup(configDir)); - - const hooksJsonPath = path.join(configDir, 'hooks.json'); - assert.ok(fs.existsSync(hooksJsonPath), 'core must write hooks.json'); - const hooksJson = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8')); - const sessionStartCmds = collectHookCommands(hooksJson, 'SessionStart'); - // The command points at the gsd-check-update hook script. Its extension is - // platform-specific — Windows routes through a .cmd shim, POSIX through .js — - // so assert on the basename prefix, not a hardcoded extension (Windows parity). - const routedToUpdateHook = sessionStartCmds.some((c) => { - const token = c.replace(/"/g, '').replace(/\\/g, '/'); - const segs = token.split('/'); - const last = segs[segs.length - 1]; - return last.startsWith('gsd-check-update.'); - }); - assert.ok( - routedToUpdateHook, - `core must route SessionStart to the gsd-check-update hook in hooks.json; got: ${JSON.stringify(sessionStartCmds)}`, - ); - }); -}); - -describe('#2695: the core profile still installs no agent files (negative space)', () => { - test('--profile=core delivers hooks but no gsd-* agent files', (t) => { - const { configDir } = runCodexInstall({ profile: 'core' }); - t.after(() => cleanup(configDir)); - - // Hooks delivered (the fix)… - for (const name of CODEX_HOOK_FILES) { - assert.ok(fs.existsSync(path.join(hooksDirOf(configDir), name)), `${name} delivered for core`); - } - // …but the full agent surface is still absent (core stays minimal). Codex agents - // are .toml ([agents.gsd-*] in config.toml + agents/gsd-*.toml), so check both - // extensions — a .md-only filter would miss a Codex agent-surface regression. - const agentsDir = path.join(configDir, 'agents'); - if (fs.existsSync(agentsDir)) { - const gsdAgents = fs.readdirSync(agentsDir).filter( - (f) => f.startsWith('gsd-') && (f.endsWith('.md') || f.endsWith('.toml')), - ); - assert.deepStrictEqual(gsdAgents, [], 'core must not install the full agent surface'); - } - // And config.toml must carry no agent role sections. - const configPath = path.join(configDir, 'config.toml'); - if (fs.existsSync(configPath)) { - const config = fs.readFileSync(configPath, 'utf8'); - assert.ok( - !/^\[agents\.gsd-/m.test(config), - 'core config.toml must not declare [agents.gsd-*] roles (full agent surface stays a full-profile concern)', - ); - } - }); -}); - -/** - * Read the `// gsd-hook-version: ` comment value from a hook file. - * Returns the trimmed literal. Used so tests assert on the structured stamp, - * not on raw `.includes()` prose (CONTRIBUTING raw-text-matching rule). - */ -function readHookVersionLine(hookPath) { - const content = fs.readFileSync(hookPath, 'utf8'); - const m = content.match(/^\/\/ gsd-hook-version:\s*(.+?)\s*$/m); - return m ? m[1] : null; -} - -/** - * Collect every hook command string registered under a given Codex hooks.json - * event key. Used so the SessionStart-wiring test asserts on the structured - * hook entries (commands), not on raw text matching against the whole file. - */ -function collectHookCommands(hooksJson, eventName) { - const entries = (hooksJson && hooksJson.hooks && Array.isArray(hooksJson.hooks[eventName])) - ? hooksJson.hooks[eventName] - : []; - return entries.flatMap((entry) => - (entry && Array.isArray(entry.hooks) ? entry.hooks : []) - .map((h) => (h && typeof h.command === 'string' ? h.command : null)) - .filter(Boolean), - ); -} - }); -} - - -describe('Codex uninstall symmetry for hook-enabled configs', () => { - let tmpDir; - let codexHome; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-codex-uninstall-')); - codexHome = path.join(tmpDir, 'codex-home'); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('fresh install removes the GSD-added codex_hooks feature on uninstall', () => { - runCodexInstall(codexHome); - - const cleaned = stripGsdFromCodexConfig(readCodexConfig(codexHome)); - assert.strictEqual(cleaned, null, 'fresh GSD-only config strips back to nothing'); - }); - - test('install then uninstall removes [features].codex_hooks while preserving other feature keys, comments, hooks, and CRLF', () => { - writeCodexConfig(codexHome, [ - '[features]', - '# keep me', - 'other_feature = true', - '', - '[[hooks]]', - 'event = "AfterCommand"', - 'command = "echo custom-after-command"', - '', - '[model]', - 'name = "o3"', - '', - ].join('\r\n')); - - runCodexInstall(codexHome); - - const cleaned = stripGsdFromCodexConfig(readCodexConfig(codexHome)); - assert.ok(cleaned, 'preserves user config after uninstall cleanup'); - assert.strictEqual(countMatches(cleaned, /^\[features\](?:\s*#.*)?$/gm), 1, 'keeps the existing features table'); - assert.strictEqual(countMatches(cleaned, /^codex_hooks = true$/gm), 0, 'removes the GSD-added codex_hooks key'); - assert.ok(cleaned.includes('# keep me'), 'preserves user comments in [features]'); - assert.ok(cleaned.includes('other_feature = true'), 'preserves other feature keys'); - assert.strictEqual(countMatches(cleaned, /echo custom-after-command/g), 1, 'preserves non-GSD hooks'); - assert.strictEqual(countMatches(cleaned, /gsd-check-update\.js/g), 0, 'removes only the GSD update hook'); - assert.strictEqual(countMatches(cleaned, /\[agents\.gsd-/g), 0, 'removes managed GSD agent sections'); - assertUsesOnlyEol(cleaned, '\r\n'); - }); - - test('install then uninstall removes dotted features.codex_hooks without creating a [features] table', () => { - writeCodexConfig(codexHome, [ - 'features.other_feature = true', - '', - '[[hooks]]', - 'event = "AfterCommand"', - 'command = "echo custom-after-command"', - '', - '[model]', - 'name = "o3"', - '', - ].join('\n')); - - runCodexInstall(codexHome); - - const cleaned = stripGsdFromCodexConfig(readCodexConfig(codexHome)); - assert.ok(cleaned.includes('features.other_feature = true'), 'preserves other dotted feature keys'); - assert.strictEqual(countMatches(cleaned, /^features\.codex_hooks = true$/gm), 0, 'removes the dotted GSD codex_hooks key'); - assert.strictEqual(countMatches(cleaned, /^\[features\]\s*$/gm), 0, 'does not leave behind a [features] table'); - assert.strictEqual(countMatches(cleaned, /echo custom-after-command/g), 1, 'preserves non-GSD hooks'); - assert.strictEqual(countMatches(cleaned, /gsd-check-update\.js/g), 0, 'removes the GSD update hook'); - }); - - test('install then uninstall preserves a pre-existing [features].codex_hooks = true', () => { - writeCodexConfig(codexHome, [ - '[features]', - 'codex_hooks = true', - 'other_feature = true', - '', - '[model]', - 'name = "o3"', - '', - ].join('\n')); - - runCodexInstall(codexHome); - - const cleaned = stripGsdFromCodexConfig(readCodexConfig(codexHome)); - assert.ok(cleaned.includes('[features]\ncodex_hooks = true\nother_feature = true'), 'preserves the user-authored codex_hooks assignment'); - assert.strictEqual(countMatches(cleaned, /^codex_hooks = true$/gm), 1, 'keeps the pre-existing codex_hooks key'); - assert.strictEqual(countMatches(cleaned, /gsd-check-update\.js/g), 0, 'removes the GSD update hook'); - assert.strictEqual(countMatches(cleaned, /\[agents\.gsd-/g), 0, 'removes managed GSD agent sections'); - }); - - test('install then uninstall preserves a pre-existing quoted [features]."codex_hooks" = true', () => { - writeCodexConfig(codexHome, [ - '[features]', - '"codex_hooks" = true', - 'other_feature = true', - '', - '[model]', - 'name = "o3"', - '', - ].join('\n')); - - runCodexInstall(codexHome); - - const cleaned = stripGsdFromCodexConfig(readCodexConfig(codexHome)); - assert.ok(cleaned.includes('[features]\n"codex_hooks" = true\nother_feature = true'), 'preserves the user-authored quoted codex_hooks assignment'); - assert.strictEqual(countMatches(cleaned, /^"codex_hooks" = true$/gm), 1, 'keeps the pre-existing quoted codex_hooks key'); - assert.strictEqual(countMatches(cleaned, /gsd-check-update\.js/g), 0, 'removes the GSD update hook'); - assert.strictEqual(countMatches(cleaned, /\[agents\.gsd-/g), 0, 'removes managed GSD agent sections'); - }); - - test('install then uninstall preserves a pre-existing root dotted features.codex_hooks = true', () => { - writeCodexConfig(codexHome, [ - 'features.codex_hooks = true', - 'features.other_feature = true', - '', - '[model]', - 'name = "o3"', - '', - ].join('\n')); - - runCodexInstall(codexHome); - - const cleaned = stripGsdFromCodexConfig(readCodexConfig(codexHome)); - assert.ok(cleaned.includes('features.codex_hooks = true\nfeatures.other_feature = true'), 'preserves the user-authored dotted codex_hooks assignment'); - assert.strictEqual(countMatches(cleaned, /^features\.codex_hooks = true$/gm), 1, 'keeps the pre-existing dotted codex_hooks key'); - assert.strictEqual(countMatches(cleaned, /gsd-check-update\.js/g), 0, 'removes the GSD update hook'); - assert.strictEqual(countMatches(cleaned, /\[agents\.gsd-/g), 0, 'removes managed GSD agent sections'); - }); - - test('install then uninstall leaves short-circuited root features assignments untouched', () => { - const cases = [ - 'features = { other_feature = true }\n\n[model]\nname = "o3"\n', - 'features = "disabled"\n\n[model]\nname = "o3"\n', - ]; - - for (const initialContent of cases) { - writeCodexConfig(codexHome, initialContent); - runCodexInstall(codexHome); - - const cleaned = stripGsdFromCodexConfig(readCodexConfig(codexHome)); - assert.strictEqual(cleaned, initialContent, `preserves short-circuited root features assignment: ${initialContent.split(/\r?\n/)[0]}`); - - cleanup(codexHome); - fs.mkdirSync(codexHome, { recursive: true }); - } - }); - - test('install then uninstall keeps mixed-EOL user content stable while removing GSD hook state', () => { - const initialContent = [ - '# first line wins', - '[features]', - 'other_feature = true', - '', - '[model]', - 'name = "o3"', - '', - ].join('\r\n').replace(/^# first line wins\r\r?\n/, '# first line wins\n'); - - writeCodexConfig(codexHome, initialContent); - runCodexInstall(codexHome); - - const cleaned = stripGsdFromCodexConfig(readCodexConfig(codexHome)); - assert.ok(cleaned.includes('# first line wins\n[features]\r\nother_feature = true\r\n\r\n[model]\r\nname = "o3"'), 'preserves the original mixed-EOL user content'); - assert.strictEqual(countMatches(cleaned, /^codex_hooks = true$/gm), 0, 'removes the injected codex_hooks key'); - assert.strictEqual(countMatches(cleaned, /gsd-check-update\.js/g), 0, 'removes the GSD update hook'); - assert.strictEqual(countMatches(cleaned, /\[agents\.gsd-/g), 0, 'removes managed GSD agent sections'); - }); -}); - -// ─── #1326: cleanupCodexSkillMetadataSidecars (replaces #774 writeCodexSkillMetadataFiles) ── - -describe('cleanupCodexSkillMetadataSidecars (#1326)', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-sidecar-cleanup-')); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('Codex install does not emit managed agents/openai.yaml sidecars and removes stale ones (#1326)', () => { - // gsd-foo: managed skill with stale sidecar → sidecar removed, empty agents/ pruned - const fooAgents = path.join(tmpDir, 'gsd-foo', 'agents'); - fs.mkdirSync(fooAgents, { recursive: true }); - fs.writeFileSync(path.join(tmpDir, 'gsd-foo', 'SKILL.md'), '---\nname: gsd-foo\n---\nBody.\n'); - fs.writeFileSync(path.join(fooAgents, 'openai.yaml'), 'interface:\n display_name: "foo"\n'); - - // gsd-dev-preferences: user-owned → sidecar PRESERVED - const prefAgents = path.join(tmpDir, 'gsd-dev-preferences', 'agents'); - fs.mkdirSync(prefAgents, { recursive: true }); - fs.writeFileSync(path.join(tmpDir, 'gsd-dev-preferences', 'SKILL.md'), '---\nname: gsd-dev-preferences\n---\nBody.\n'); - const userYaml = 'interface:\n display_name: "my prefs"\n short_description: "User-authored"\n'; - fs.writeFileSync(path.join(prefAgents, 'openai.yaml'), userYaml); - - // gsd-bar: managed skill with sidecar + another file in agents/ → sidecar removed, agents/ kept (has other.txt) - const barAgents = path.join(tmpDir, 'gsd-bar', 'agents'); - fs.mkdirSync(barAgents, { recursive: true }); - fs.writeFileSync(path.join(tmpDir, 'gsd-bar', 'SKILL.md'), '---\nname: gsd-bar\n---\nBody.\n'); - fs.writeFileSync(path.join(barAgents, 'openai.yaml'), 'interface:\n display_name: "bar"\n'); - fs.writeFileSync(path.join(barAgents, 'other.txt'), 'some other content\n'); - - // helper: non-gsd dir with openai.yaml → UNTOUCHED - const helperAgents = path.join(tmpDir, 'helper', 'agents'); - fs.mkdirSync(helperAgents, { recursive: true }); - fs.writeFileSync(path.join(helperAgents, 'openai.yaml'), 'interface:\n display_name: "helper"\n'); - - cleanupCodexSkillMetadataSidecars(tmpDir); - - // gsd-foo: sidecar removed and empty agents/ pruned - assert.ok(!fs.existsSync(path.join(fooAgents, 'openai.yaml')), - 'gsd-foo/agents/openai.yaml must be removed (managed stale sidecar)'); - assert.ok(!fs.existsSync(fooAgents), - 'gsd-foo/agents/ must be pruned when empty after sidecar removal'); - - // gsd-dev-preferences: user-owned, sidecar preserved - assert.ok(fs.existsSync(path.join(prefAgents, 'openai.yaml')), - 'gsd-dev-preferences/agents/openai.yaml must be preserved (user-owned)'); - assert.strictEqual(fs.readFileSync(path.join(prefAgents, 'openai.yaml'), 'utf8'), userYaml, - 'gsd-dev-preferences/agents/openai.yaml content must be unchanged'); - - // gsd-bar: sidecar removed but agents/ kept (still has other.txt) - assert.ok(!fs.existsSync(path.join(barAgents, 'openai.yaml')), - 'gsd-bar/agents/openai.yaml must be removed'); - assert.ok(fs.existsSync(barAgents), - 'gsd-bar/agents/ must NOT be pruned (still contains other.txt)'); - assert.ok(fs.existsSync(path.join(barAgents, 'other.txt')), - 'gsd-bar/agents/other.txt must be preserved'); - - // helper: non-gsd dir untouched - assert.ok(fs.existsSync(path.join(helperAgents, 'openai.yaml')), - 'helper/agents/openai.yaml must be untouched (non-gsd dir)'); - }); - - test('is a no-op when skillsDir does not exist (#1326)', () => { - assert.doesNotThrow(() => { - cleanupCodexSkillMetadataSidecars(path.join(tmpDir, 'nonexistent')); - }, 'must not throw when skillsDir does not exist'); - }); - - test('is a no-op for managed gsd-* dirs with no agents/openai.yaml (#1326)', () => { - // No sidecar present — should not throw, should not create anything - const skillDir = path.join(tmpDir, 'gsd-baz'); - fs.mkdirSync(skillDir, { recursive: true }); - fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '---\nname: gsd-baz\n---\nBody.\n'); - - assert.doesNotThrow(() => { - cleanupCodexSkillMetadataSidecars(tmpDir); - }, 'must not throw when no sidecar exists'); - assert.ok(!fs.existsSync(path.join(skillDir, 'agents')), - 'must not create agents/ dir when no sidecar was present'); - }); - - test('does not delete through a symlinked agents/ directory (#1326)', { skip: process.platform === 'win32' }, () => { - // Setup: a skills dir with gsd-foo/ whose agents/ is a SYMLINK to an external dir. - // The cleanup must not delete files through the symlink. - const externalDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-symlink-ext-')); - try { - // Place openai.yaml and a sentinel in the external dir. - fs.writeFileSync(path.join(externalDir, 'openai.yaml'), 'interface:\n display_name: "external"\n'); - fs.writeFileSync(path.join(externalDir, 'keep.txt'), 'sentinel\n'); - - // Create gsd-foo/ in the skills dir and make agents/ a symlink to externalDir. - const skillDir = path.join(tmpDir, 'gsd-foo'); - fs.mkdirSync(skillDir, { recursive: true }); - const agentsLink = path.join(skillDir, 'agents'); - fs.symlinkSync(externalDir, agentsLink, 'dir'); - - cleanupCodexSkillMetadataSidecars(tmpDir); - - // Nothing in the external dir must have been deleted. - assert.ok(fs.existsSync(path.join(externalDir, 'openai.yaml')), - 'external/openai.yaml must still exist — cleanup must not delete through a symlinked agents/ dir'); - assert.ok(fs.existsSync(path.join(externalDir, 'keep.txt')), - 'external/keep.txt must still exist — cleanup must not delete through a symlinked agents/ dir'); - // The symlink itself must still be present. - assert.ok(fs.existsSync(agentsLink), - 'gsd-foo/agents symlink must still exist'); - } finally { - cleanup(externalDir); - } - }); - - test('Codex install does not create agents/openai.yaml sidecars for any managed skill (#1326)', () => { - // Integration test: full Codex install must NOT produce any managed gsd-*/agents/openai.yaml - const codexHome = path.join(tmpDir, 'codex-home'); - fs.mkdirSync(codexHome, { recursive: true }); - runCodexInstall(codexHome); - 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'); - assert.ok(gsdSkillDirs.length > 0, 'install must create at least one managed gsd-* skill directory'); - for (const skillEntry of gsdSkillDirs) { - const yamlPath = path.join(skillsDir, skillEntry.name, 'agents', 'openai.yaml'); - assert.ok(!fs.existsSync(yamlPath), - `${skillEntry.name}/agents/openai.yaml must NOT exist after install (#1326 sidecar dedup)`); - } - }); -}); - - -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/bug-2698-crlf-install.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:bug-2698-crlf-install (consolidation epic #1969 B1 #1970)", () => { -// allow-test-rule: source-text-is-the-product (see #2698) -// Workflow .md / agent .md / command .md / reference .md files — their text -// IS what the runtime loads. Testing text content tests the deployed contract. -// Per CONTRIBUTING.md exception matrix. - -/** - * Regression test for #2698: CRLF line endings break agent-block strip regexes - * - * The legacy `gsd-update-check` hook migration in bin/install.js uses two - * separate .replace() calls: - * 1. LF-only regex: /\n# GSD Hooks\n\[\[hooks\]\]\nevent = ...\n/ - * 2. CRLF-only regex: /\r\n# GSD Hooks\r\n\[\[hooks\]\]\r\nevent = ...\r\n/ - * - * These patterns fail when config.toml has mixed line endings — e.g. the - * "# GSD Hooks" header uses LF but the body uses CRLF, or vice versa. This - * can happen when the file is created cross-platform (Windows/Linux), when - * editors convert only part of the file, or when a previous GSD version wrote - * the block with different EOL than the file's dominant EOL. - * - * Fix: consolidate to a single \r?\n-aware regex that handles LF, CRLF, and - * any mix in a single pass, making the migration robust regardless of the - * platform the file was last written on. - * - * Test approach: write a `.codex/config.toml` with a stale gsd-update-check - * block that uses mixed line endings (header in LF, body in CRLF), then run - * install() and assert the stale block is gone. - * - * Note: The local Codex install writes to `.codex/` in the current directory. - * Tests `process.chdir(tmpDir)` and write fixtures to `tmpDir/.codex/`. - */ - -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -const { describe, test, before, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); -const os = require('os'); -const { runNode } = require('./helpers/process-seam.cjs'); -const { throwIfFailed } = require('./helpers/git-fixture.cjs'); - -const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js'); -const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); -// scripts/build-hooks.js copies pre-built hook files into hooks/dist and -// syntax-checks them with vm — it does not compile/bundle anything. See -// tests/helpers/timeouts.cjs for the class-norm justification. -const { BUILD_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); -const { install, GSD_CODEX_MARKER } = require(INSTALL_SRC); -const { cleanup } = require('./helpers.cjs'); - -// Ensure hooks/dist/ is populated before install tests -before(() => { - throwIfFailed( - runNode([BUILD_SCRIPT], { timeoutMs: BUILD_TIMEOUT_MS }), - `node ${BUILD_SCRIPT}`, - ); -}); - -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); - }); - - // Helper: pre-populate .codex/config.toml with a GSD marker + stale hooks block - // using the given line ending for the stale hooks block header, and a potentially - // different EOL for the hooks body. This exercises the cross-platform mixed scenario. -function writeCodexConfigWithStaleHooks(dir, headerEol, bodyEol) { - // Build the stale block with header EOL for the "# GSD Hooks" line, but body EOL - // for the content lines (simulates a file edited by two different platforms). - const staleBlock = [ - '# GSD Hooks', // line that starts the stale section - '[[hooks]]', - 'event = "SessionStart"', - 'command = "node /old/path/gsd-update-check.js"', - ].join(bodyEol); - - // Put the stale block in user content BEFORE the GSD marker. The GSD marker area - // will be regenerated by mergeCodexConfig during install(); the stale block in - // the user area is what the hooks migration must remove. - const content = [ - '[features]', - 'codex_hooks = true', - '', - ].join(headerEol) + headerEol + staleBlock + headerEol + headerEol + GSD_CODEX_MARKER + headerEol; - - const codexDir = path.join(dir, '.codex'); - fs.mkdirSync(codexDir, { recursive: true }); - const configPath = path.join(codexDir, 'config.toml'); - fs.writeFileSync(configPath, content, 'utf-8'); - return configPath; - } - - function readHooksSessionStartCommands(codexHome) { - const hooksPath = path.join(codexHome, 'hooks.json'); - if (!fs.existsSync(hooksPath)) return []; - const raw = fs.readFileSync(hooksPath, 'utf8').trim(); - if (!raw) return []; - const parsed = JSON.parse(raw); - const table = (parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks)) - ? parsed.hooks - : parsed; - const sessionStart = Array.isArray(table.SessionStart) ? table.SessionStart : []; - return sessionStart.flatMap((entry) => [ - ...(typeof entry?.command === 'string' ? [entry.command] : []), - ...(Array.isArray(entry?.hooks) - ? entry.hooks.map((hook) => hook && hook.command).filter((cmd) => typeof cmd === 'string') - : []), - ]); - } - - test('LF config.toml: stale gsd-update-check block removed on reinstall', (t) => { - const origCwd = process.cwd(); - t.after(() => { process.chdir(origCwd); }); - process.chdir(tmpDir); - - writeCodexConfigWithStaleHooks(tmpDir, '\n', '\n'); - install(false, 'codex'); - - const configPath = path.join(tmpDir, '.codex', 'config.toml'); - const content = fs.readFileSync(configPath, 'utf-8'); - - assert.ok( - !content.includes('gsd-update-check'), - 'Stale gsd-update-check entry must be removed from LF config.toml (#2698)' - ); - const hooksJsonCommands = readHooksSessionStartCommands(path.join(tmpDir, '.codex')); - assert.equal( - hooksJsonCommands.some((cmd) => cmd.includes('gsd-check-update')), - true, - 'New gsd-check-update hook must appear in hooks.json after reinstall' - ); - }); - - test('CRLF config.toml: stale gsd-update-check block removed on reinstall', (t) => { - const origCwd = process.cwd(); - t.after(() => { process.chdir(origCwd); }); - process.chdir(tmpDir); - - writeCodexConfigWithStaleHooks(tmpDir, '\r\n', '\r\n'); - install(false, 'codex'); - - const configPath = path.join(tmpDir, '.codex', 'config.toml'); - const content = fs.readFileSync(configPath, 'utf-8'); - - assert.ok( - !content.includes('gsd-update-check'), - 'Stale gsd-update-check entry must be removed from CRLF config.toml (#2698)' - ); - const hooksJsonCommands = readHooksSessionStartCommands(path.join(tmpDir, '.codex')); - assert.equal( - hooksJsonCommands.some((cmd) => cmd.includes('gsd-check-update')), - true, - 'New gsd-check-update hook must appear in hooks.json after reinstall' - ); - }); - - test('mixed-EOL config.toml: stale block with LF header but CRLF body removed on reinstall', (t) => { - // This is the primary failure case: header line uses LF but the body uses CRLF. - // The old LF-only regex requires all-\n separators; the old CRLF-only regex requires - // all-\r\n separators. Neither matches a block with mixed endings, so the stale - // block survives reinstall with the old code (#2698). - const origCwd = process.cwd(); - t.after(() => { process.chdir(origCwd); }); - process.chdir(tmpDir); - - // headerEol='\n' (file dominant), bodyEol='\r\n' (hook block from another platform) - writeCodexConfigWithStaleHooks(tmpDir, '\n', '\r\n'); - install(false, 'codex'); - - const configPath = path.join(tmpDir, '.codex', 'config.toml'); - const content = fs.readFileSync(configPath, 'utf-8'); - - assert.ok( - !content.includes('gsd-update-check'), - [ - 'Stale gsd-update-check block with mixed LF/CRLF endings must be removed (#2698).', - 'Old code used two separate LF-only and CRLF-only regexes; neither matched mixed content.', - 'Fix consolidates to a single \\r?\\n-aware regex.', - ].join(' ') - ); - }); -}); - }); -} - - -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/bug-2760-codex-install-defensive.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:bug-2760-codex-install-defensive (consolidation epic #1969 B1 #1970)", () => { -/** - * Regression: issue #2760 — Codex install path corrupts existing config.toml. - * - * Three defects, three fixes (defensive triple): - * - * Defect 3 (confirmed real) — Hooks AoT downgrade. When the user already has - * `[[hooks.SessionStart]]` (namespaced AoT) entries in their config, GSD - * used to append a `[[hooks]]` (top-level AoT) block that confuses - * round-trip writers and produces a config Codex refuses to load. - * Fix: detect the user's preferred shape and emit GSD's hook in the same - * namespaced form so both coexist cleanly. - * - * Defects 1+2 (defensive) — Strip-step robustness. Pre-existing legacy - * `[agents]` (single-bracket) and `[[agents]]` (sequence) blocks are - * invalid in current Codex schema and break Codex even though GSD now - * emits the correct `[agents.]` struct form. Fix: install-time - * stripping always purges these forms regardless of GSD marker presence - * so reinstall self-heals files where the marker was edited out or never - * existed (third-party tools). - * - * Fix 3 (defensive) — Post-write validation. Parse the bytes we are about - * to commit, assert they match Codex's expected schema (no bare/sequence - * `agents`, no bare `hooks.`); on failure, restore the pre-install - * backup and abort so the user never gets a broken Codex CLI. - */ - -// Scope GSD_TEST_MODE to module load only — restore prior value (or unset) so -// downstream tests in the same node process never see test-only behaviour -// leak through (#2760 CR4 finding 5). -const previousGsdTestMode = process.env.GSD_TEST_MODE; -process.env.GSD_TEST_MODE = '1'; - -const { test, describe, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); -const os = require('os'); - -const { - install, - validateCodexConfigSchema, - hasUserNamespacedAotHooks, - parseTomlToObject, -} = require('../bin/install.js'); - -const { cleanup } = require('./helpers.cjs'); - -if (previousGsdTestMode === undefined) { - delete process.env.GSD_TEST_MODE; -} else { - process.env.GSD_TEST_MODE = previousGsdTestMode; -} - -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'); - } finally { - process.chdir(previousCwd); - 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; - } -} - -function readCodexConfig(codexHome) { - return fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8'); -} - -function writeCodexConfig(codexHome, content) { - fs.mkdirSync(codexHome, { recursive: true }); - fs.writeFileSync(path.join(codexHome, 'config.toml'), content, 'utf8'); -} - -function readCodexHooksJson(codexHome) { - const hooksPath = path.join(codexHome, 'hooks.json'); - if (!fs.existsSync(hooksPath)) return {}; - const raw = fs.readFileSync(hooksPath, 'utf8').trim(); - if (!raw) return {}; - return JSON.parse(raw); -} - -function readHooksSessionStartCommands(codexHome) { - const parsed = readCodexHooksJson(codexHome); - const table = (parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks)) - ? parsed.hooks - : parsed; - const sessionStart = Array.isArray(table.SessionStart) ? table.SessionStart : []; - return sessionStart.flatMap((entry) => - (Array.isArray(entry?.hooks) ? entry.hooks : []) - .map((hook) => hook && hook.command) - .filter((cmd) => typeof cmd === 'string') - ); -} - -describe('#2760 defect 3 — Hooks AoT preservation across install/uninstall/reinstall', () => { - let tmpDir; - let codexHome; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2760-d3-')); - codexHome = path.join(tmpDir, 'codex-home'); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('fresh install emits the two-level nested AoT schema (#2773)', () => { - // Codex 0.124.0+ requires [[hooks.SessionStart]] + [[hooks.SessionStart.hooks]] - // with type = "command". Neither the flat [[hooks]] + event field form nor - // the single-block [[hooks.SessionStart]] form without .hooks is accepted. - writeCodexConfig(codexHome, ''); - runCodexInstall(codexHome); - const content = readCodexConfig(codexHome); - const parsed = parseTomlToObject(content); - - const sessionStartCommands = readHooksSessionStartCommands(codexHome); - const managed = sessionStartCommands.filter((cmd) => /gsd-check-update/.test(cmd)); - assert.equal(managed.length, 1, 'hooks.json must contain exactly one managed gsd-check-update command'); - assert.ok( - !parsed.hooks || !Array.isArray(parsed.hooks.SessionStart), - 'config.toml should not carry managed SessionStart hooks for GSD' - ); - }); - - test('preserves user [[hooks.SessionStart]] entries and registers managed GSD handler in hooks.json', () => { - // Users may have their own [[hooks.SessionStart]] entries using the new schema. - // GSD must append its own two-level block without disturbing theirs. - const userConfig = [ - '[[hooks.SessionStart]]', - '', - '[[hooks.SessionStart.hooks]]', - 'type = "command"', - 'command = "echo first user hook"', - '', - '[[hooks.SessionStart]]', - '', - '[[hooks.SessionStart.hooks]]', - 'type = "command"', - 'command = "echo second user hook"', - '', - ].join('\n'); - writeCodexConfig(codexHome, userConfig); - - runCodexInstall(codexHome); - const afterInstall = readCodexConfig(codexHome); - const parsed = parseTomlToObject(afterInstall); - - assert.ok( - parsed.hooks && Array.isArray(parsed.hooks.SessionStart), - 'hooks.SessionStart must remain an array-of-tables after install' - ); - - // Collect all handler commands across all event entries. - const allCommands = parsed.hooks.SessionStart.flatMap((entry) => - Array.isArray(entry.hooks) ? entry.hooks.map((h) => h.command) : [] - ); - - assert.ok( - allCommands.includes('echo first user hook'), - 'first user hook preserved: ' + JSON.stringify(allCommands) - ); - assert.ok( - allCommands.includes('echo second user hook'), - 'second user hook preserved: ' + JSON.stringify(allCommands) - ); - const hooksJsonCommands = readHooksSessionStartCommands(codexHome); - assert.ok( - hooksJsonCommands.some((cmd) => typeof cmd === 'string' && /gsd-check-update/.test(cmd)), - 'GSD handler must appear in hooks.json SessionStart entries: ' + JSON.stringify(hooksJsonCommands) - ); - assert.ok(!Array.isArray(parsed.hooks), 'no flat [[hooks]] entries'); - }); - - test('reinstall replaces flat [[hooks]] + event form with nested schema', () => { - // Upgrade path: user has a config written by GSD 1.38.x (flat [[hooks]] form). - const legacyConfig = [ - '[features]', - 'codex_hooks = true', - '', - '# GSD Hooks', - '[[hooks]]', - 'event = "SessionStart"', - 'command = "node /old/path/to/gsd-check-update.js"', - '', - ].join('\n'); - writeCodexConfig(codexHome, legacyConfig); - - runCodexInstall(codexHome); - const content = readCodexConfig(codexHome); - const parsed = parseTomlToObject(content); - - // Old flat form must be gone. - assert.ok(!Array.isArray(parsed.hooks), 'flat [[hooks]] must be stripped on upgrade'); - // Only one GSD hook entry must exist (no duplication) in hooks.json. - const hooksJsonCommands = readHooksSessionStartCommands(codexHome); - const gsdHandlers = hooksJsonCommands.filter((cmd) => /gsd-check-update/.test(cmd)); - assert.strictEqual(gsdHandlers.length, 1, 'exactly one managed handler after upgrade'); - }); - - test('reinstall replaces single-block [[hooks.SessionStart]] (no .hooks sub-table) with nested schema', () => { - // Upgrade path: user has a config written by the PR #2802 shape — - // [[hooks.SessionStart]] without a nested [[hooks.SessionStart.hooks]] sub-table. - const prBranchConfig = [ - '[features]', - 'codex_hooks = true', - '', - '# GSD Hooks', - '[[hooks.SessionStart]]', - 'command = "node /old/path/to/gsd-check-update.js"', - '', - ].join('\n'); - writeCodexConfig(codexHome, prBranchConfig); - - runCodexInstall(codexHome); - const content = readCodexConfig(codexHome); - parseTomlToObject(content); - - const hooksJsonCommands = readHooksSessionStartCommands(codexHome); - const gsdHandlers = hooksJsonCommands.filter((cmd) => /gsd-check-update/.test(cmd)); - assert.strictEqual(gsdHandlers.length, 1, 'exactly one managed handler after upgrade from PR-#2802-shape'); - }); - - test('reinstall is idempotent: correct nested schema is stripped and re-emitted cleanly', () => { - writeCodexConfig(codexHome, ''); - runCodexInstall(codexHome); - runCodexInstall(codexHome); // second install - readCodexConfig(codexHome); - - const hooksJsonCommands = readHooksSessionStartCommands(codexHome); - const gsdHandlers = hooksJsonCommands.filter((cmd) => /gsd-check-update/.test(cmd)); - assert.strictEqual(gsdHandlers.length, 1, 'exactly one managed SessionStart handler after double install'); - }); -}); - -describe('#2760 fix 2 — Strip purges invalid legacy [agents] / [[agents]] regardless of marker', () => { - let tmpDir; - let codexHome; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2760-f2-')); - codexHome = path.join(tmpDir, 'codex-home'); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('strips bare [agents] single-bracket block (no GSD marker, arbitrary user keys)', () => { - writeCodexConfig(codexHome, [ - '[agents]', - 'default = "custom-agent"', - 'extra_key = "value"', - '', - '[model]', - 'name = "o3"', - '', - ].join('\n')); - - runCodexInstall(codexHome); - const content = readCodexConfig(codexHome); - const parsed = parseTomlToObject(content); - - // Bare [agents] would have left { default, extra_key } as scalar leaves - // on parsed.agents. After strip + re-emit, only GSD's own managed - // AgentsToml scalar (max_depth) remains — #2406 stopped emitting - // [agents.] role sub-tables entirely, so `agents` stays a flat - // scalar-only object, not a table-of-tables. - assert.ok( - parsed.agents && typeof parsed.agents === 'object' && !Array.isArray(parsed.agents), - 'agents must be an object in parsed structure, got: ' + typeof parsed.agents - ); - assert.equal(parsed.agents.default, undefined, 'bare [agents] default key must be stripped'); - assert.equal(parsed.agents.extra_key, undefined, 'bare [agents] extra_key must be stripped'); - assert.equal(parsed.agents.max_depth, 1, 'GSD-managed max_depth is the only surviving [agents] key'); - const gsdAgents = Object.keys(parsed.agents).filter((k) => k.startsWith('gsd-')); - assert.deepStrictEqual( - gsdAgents, [], - 'no [agents.gsd-*] role sub-tables (#2406) — canonical registration lives only in the standalone TOMLs: ' + JSON.stringify(Object.keys(parsed.agents)) - ); - - // User's unrelated [model] section preserved structurally. - assert.ok( - parsed.model && parsed.model.name === 'o3', - 'unrelated user [model] section preserved with name = "o3", got: ' + JSON.stringify(parsed.model) - ); - }); - - test('strips [[agents]] sequence-form block without GSD marker (third-party / marker-edited-out)', () => { - writeCodexConfig(codexHome, [ - '[[agents]]', - 'name = "user-helper"', - 'description = "third-party agent"', - '', - '[[agents]]', - 'name = "another-helper"', - 'description = "second one"', - '', - '[projects."/tmp/x"]', - 'trust_level = "trusted"', - '', - ].join('\n')); - - runCodexInstall(codexHome); - const content = readCodexConfig(codexHome); - const parsed = parseTomlToObject(content); - - // [[agents]] sequence form would parse to Array — after strip it must be - // a plain object holding only GSD's own managed max_depth scalar (#2406 - // stopped emitting [agents.] role sub-tables entirely). - assert.ok( - parsed.agents && typeof parsed.agents === 'object' && !Array.isArray(parsed.agents), - 'agents must be an object in parsed structure (sequence form must be stripped), got: ' - + (Array.isArray(parsed.agents) ? 'array' : typeof parsed.agents) - ); - assert.equal(parsed.agents.max_depth, 1, 'GSD-managed max_depth is the only surviving [agents] key'); - const gsdAgents = Object.keys(parsed.agents).filter((k) => k.startsWith('gsd-')); - assert.deepStrictEqual( - gsdAgents, [], - 'no [agents.gsd-*] role sub-tables (#2406) — canonical registration lives only in the standalone TOMLs: ' + JSON.stringify(Object.keys(parsed.agents)) - ); - - // User's unrelated [projects."/tmp/x"] section preserved structurally. - assert.ok( - parsed.projects && parsed.projects['/tmp/x'] && parsed.projects['/tmp/x'].trust_level === 'trusted', - 'unrelated user [projects."/tmp/x"] section preserved with trust_level = "trusted", got: ' - + JSON.stringify(parsed.projects) - ); - }); -}); - -// concurrency: false — the third test mutates installModule.__codexSchemaValidator, -// a module-level test seam. Other tests in this file (and in bug-2153, etc.) -// also call runCodexInstall() and would observe the injected validator if -// node:test ran them in parallel. Serializing this describe block keeps the -// seam mutation invisible to siblings. -describe('#2760 fix 3 — Post-write Codex schema validation', { concurrency: false }, () => { - test('passes a clean config produced by GSD install', () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2760-f3a-')); - try { - const codexHome = path.join(tmpDir, 'codex-home'); - runCodexInstall(codexHome); - const content = readCodexConfig(codexHome); - const result = validateCodexConfigSchema(content); - assert.equal(result.ok, true, 'GSD-emitted config passes schema validation'); - } finally { - cleanup(tmpDir); - } - }); - - test('rejects bare [agents] and bare [hooks.SessionStart] in arbitrary content', () => { - const bareAgents = [ - '[agents]', - 'default = "x"', - '', - ].join('\n'); - const bareHooks = [ - '[hooks.SessionStart]', - 'command = "x"', - '', - ].join('\n'); - const sequenceAgents = [ - '[[agents]]', - 'name = "x"', - '', - ].join('\n'); - - assert.equal(validateCodexConfigSchema(bareAgents).ok, false, 'bare [agents] rejected'); - assert.equal(validateCodexConfigSchema(bareHooks).ok, false, 'bare [hooks.SessionStart] rejected'); - assert.equal(validateCodexConfigSchema(sequenceAgents).ok, false, '[[agents]] sequence rejected'); - }); - - test('aborts install and restores pre-install backup when post-write validation fails', () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2760-f3b-')); - const installModule = require('../bin/install.js'); - try { - const codexHome = path.join(tmpDir, 'codex-home'); - // Pre-install file the user wants protected. - const preInstall = [ - '# user file', - '[model]', - 'name = "o3"', - '', - ].join('\n'); - writeCodexConfig(codexHome, preInstall); - - // Force the post-write validator to fail via the documented test seam. - // This simulates the writer producing legacy-form output that Codex - // would reject — install MUST abort, restore the pre-install bytes, - // and surface a clear error. - installModule.__codexSchemaValidator = () => ({ - ok: false, - reason: 'simulated invalid output for test', - }); - - let threw = false; - try { - runCodexInstall(codexHome); - } catch (e) { - threw = true; - assert.match( - e.message, - /post-write Codex schema validation failed/, - 'thrown error names the validation failure' - ); - assert.match(e.message, /simulated invalid output for test/, 'thrown error includes reason'); - } - assert.equal(threw, true, 'install threw when validator failed'); - - const afterInstall = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8'); - assert.equal( - afterInstall, - preInstall, - 'pre-install file restored verbatim after validation failure' - ); - } finally { - delete installModule.__codexSchemaValidator; - cleanup(tmpDir); - } - }); -}); - -describe('#2760 — hasUserNamespacedAotHooks helper', () => { - test('detects [[hooks.SessionStart]] AoT entries', () => { - const content = [ - '[[hooks.SessionStart]]', - 'command = "x"', - '', - ].join('\n'); - assert.equal(hasUserNamespacedAotHooks(content, 'SessionStart'), true); - }); - - test('returns false when only top-level [[hooks]] entries exist', () => { - const content = [ - '[[hooks]]', - 'event = "SessionStart"', - 'command = "x"', - '', - ].join('\n'); - assert.equal(hasUserNamespacedAotHooks(content, 'SessionStart'), false); - }); - - test('returns false when only single-bracket [hooks.SessionStart] exists', () => { - const content = [ - '[hooks.SessionStart]', - 'command = "x"', - '', - ].join('\n'); - assert.equal(hasUserNamespacedAotHooks(content, 'SessionStart'), false); - }); -}); - -// concurrency: false — these tests monkey-patch fs.writeFileSync, a global -// shared with every other suite running in parallel. Serializing prevents -// stray writes from sibling tests landing in the stub. -describe('#2760 fix 4 — Write-failure rollback (atomic write + snapshot restore)', { concurrency: false }, () => { - let tmpDir; - let codexHome; - let originalWriteFileSync; - // #2760 CR5 finding 5 — symmetric snapshot/restore for fs.renameSync. The - // first test below monkey-patches renameSync; without a beforeEach/afterEach - // pair, only the local `finally` restores it, which is fragile to future - // edits that add early-return paths. - let originalRenameSync; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2760-f4-')); - codexHome = path.join(tmpDir, 'codex-home'); - originalWriteFileSync = fs.writeFileSync; - originalRenameSync = fs.renameSync; - }); - - afterEach(() => { - fs.renameSync = originalRenameSync; - fs.writeFileSync = originalWriteFileSync; - cleanup(tmpDir); - }); - - test('pre-install config bytes survive when fs.renameSync throws over configPath', () => { - const preInstall = [ - '# user file', - '[model]', - 'name = "o3"', - '', - ].join('\n'); - writeCodexConfig(codexHome, preInstall); - - // After fs is restored we'll re-read the file. Capture the byte buffer - // exactly so the comparison is bit-for-bit. - const preInstallBytes = fs.readFileSync(path.join(codexHome, 'config.toml')); - - const configPath = path.join(codexHome, 'config.toml'); - const tempPattern = new RegExp('^' + escapeRegex(configPath) + '\\.tmp-'); - - // Stub: allow writes to atomic temp files (which renameSync overwrites - // the target, never truncating it directly) but throw on any direct - // write to the canonical configPath. This simulates either: - // (a) an older code path doing a non-atomic write, or - // (b) a downstream module bypassing atomicWriteFileSync. - // Either way the snapshot must be restored. We let the temp write go - // through, then make renameSync throw to simulate the partial write - // never landing. - // #2760 CR5 finding 5 — fs.renameSync is restored by the suite-level - // afterEach; no local finally needed. - fs.renameSync = (src, dst) => { - if (dst === configPath) { - throw new Error('simulated rename failure mid-install'); - } - return originalRenameSync(src, dst); - }; - - let threw = false; - let thrownErr = null; - try { - runCodexInstall(codexHome); - } catch (e) { - threw = true; - thrownErr = e; - assert.ok(/rename failure|simulated|post-write/.test(e.message), - 'thrown error must surface the simulated failure or its post-write wrapper: ' + e.message); - } - // #2760 CR5 finding 4 — tighten contract per finding #1: ALL pre-write - // and write failures must be fatal. This test previously accepted either - // throw OR warn — sibling tests already require throw, so lock parity. - assert.equal(threw, true, 'rename failure must be fatal: ' + (thrownErr && thrownErr.message)); - - const afterBytes = fs.readFileSync(path.join(codexHome, 'config.toml')); - assert.deepStrictEqual( - afterBytes, - preInstallBytes, - 'pre-install config.toml bytes must survive a mid-install write/rename failure' - ); - - // And the parsed structure of the surviving file must still be the - // user's [model] section, not a half-written GSD block. - const parsed = parseTomlToObject(afterBytes.toString('utf8')); - assert.equal(parsed.model && parsed.model.name, 'o3', - 'surviving file must still be the user pre-install content'); - assert.equal(parsed.agents, undefined, - 'no GSD agents block may have leaked into the surviving file'); - - // No stray .tmp-* siblings left behind in the codex home. - const stray = fs.readdirSync(codexHome).filter((f) => tempPattern.test(path.join(codexHome, f))); - assert.equal(stray.length, 0, - 'atomic write must clean up its temp file on failure: ' + stray.join(', ')); - }); - - test('pre-install config bytes survive when fs.writeFileSync throws on the .tmp- target', () => { - const preInstall = [ - '# user file', - '[model]', - 'name = "o3"', - '', - ].join('\n'); - writeCodexConfig(codexHome, preInstall); - - const preInstallBytes = fs.readFileSync(path.join(codexHome, 'config.toml')); - const configPath = path.join(codexHome, 'config.toml'); - const tempPattern = new RegExp('^' + escapeRegex(configPath) + '\\.tmp-'); - - // Stub: fault writes targeting the atomic temp file (the pre-rename branch - // of atomicWriteFileSync). Other writes (agent .toml files in CODEX_HOME) - // pass through. This exercises the failure path where the temp write itself - // throws, not the rename — the case the prior test left untested. - // #2760 CR5 finding 5 — fs.writeFileSync is restored by the suite-level - // afterEach (via originalWriteFileSync); no local finally needed. - const captured = originalWriteFileSync; - fs.writeFileSync = function patchedWriteFileSync(target, data, options) { - if (typeof target === 'string' && tempPattern.test(target)) { - throw new Error('simulated writeFileSync failure on .tmp- target'); - } - return captured.call(this, target, data, options); - }; - - let threw = false; - try { - runCodexInstall(codexHome); - } catch (e) { - threw = true; - assert.ok(/simulated writeFileSync failure|post-write Codex install failed|pre-write/.test(e.message), - 'thrown error must surface the simulated failure or its post-write wrapper: ' + e.message); - } - // Per #2760 CR4 finding 1 / CR5 finding 1, write failures must abort install (not warn). - assert.equal(threw, true, 'install must throw when atomic temp-write fails'); - - const afterBytes = fs.readFileSync(path.join(codexHome, 'config.toml')); - assert.deepStrictEqual( - afterBytes, - preInstallBytes, - 'pre-install config.toml bytes must survive a temp-write failure' - ); - - const parsed = parseTomlToObject(afterBytes.toString('utf8')); - assert.equal(parsed.model && parsed.model.name, 'o3', - 'surviving file must still be the user pre-install content'); - assert.equal(parsed.agents, undefined, - 'no GSD agents block may have leaked into the surviving file'); - - const stray = fs.readdirSync(codexHome).filter((f) => tempPattern.test(path.join(codexHome, f))); - assert.equal(stray.length, 0, - 'atomic write must clean up its temp file on failure: ' + stray.join(', ')); - }); -}); - -// concurrency: false — these tests rely on the same install path and module- -// level pre-install snapshot that the fix-3/fix-4 suites exercise. Serializing -// keeps state mutations from leaking across parallel siblings. -describe('#2760 CR4 finding 2 — Legacy flat [[hooks]] block migrates to namespaced AoT on reinstall', { concurrency: false }, () => { - let tmpDir; - let codexHome; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2760-cr4-f2-')); - codexHome = path.join(tmpDir, 'codex-home'); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('pre-install legacy flat [[hooks]] gsd-check-update + user namespaced [[hooks.SessionStart]] → post-install converges on namespaced AoT', () => { - // Reproduce the upgrade scenario: - // - User has [[hooks.SessionStart]] entry of their own (signal that GSD - // should emit in the namespaced shape). - // - A previous GSD install left the legacy flat [[hooks]] managed block - // for gsd-check-update. The pre-CR4 strip step would short-circuit - // the namespaced emit and leave the user stuck in the mixed layout. - const userPlusLegacy = [ - '[[hooks.SessionStart]]', - 'command = "echo user hook"', - '', - '# GSD Hooks', - '[[hooks]]', - 'event = "SessionStart"', - 'command = "node /old/path/hooks/gsd-check-update.js"', - '', - ].join('\n'); - writeCodexConfig(codexHome, userPlusLegacy); - - runCodexInstall(codexHome); - const afterInstall = readCodexConfig(codexHome); - const parsed = parseTomlToObject(afterInstall); - - // After CR4 finding 2: the legacy flat [[hooks]] managed block is stripped - // and the GSD entry is re-emitted in the namespaced AoT shape so the two - // forms do not coexist. - assert.ok( - parsed.hooks && Array.isArray(parsed.hooks.SessionStart), - 'hooks.SessionStart must be an array-of-tables, got: ' - + (parsed.hooks ? typeof parsed.hooks.SessionStart : 'no hooks table') - ); - - // Migration now handles stale [[hooks.SessionStart]] entries with handler - // fields at event-entry level (pre-#2773 shape), promoting them to the - // two-level nested form. Every entry must carry a .hooks sub-array after - // migration, so collect from nested handlers only. - assert.ok( - parsed.hooks.SessionStart.every((entry) => Array.isArray(entry.hooks)), - 'every hooks.SessionStart entry must use nested [[hooks.SessionStart.hooks]] handlers after migration' - ); - const allSessionStartCommands = parsed.hooks.SessionStart.flatMap((entry) => - entry.hooks.map((h) => h.command).filter(Boolean) - ); - assert.ok( - allSessionStartCommands.includes('echo user hook'), - 'user [[hooks.SessionStart]] entry preserved: ' + JSON.stringify(allSessionStartCommands) - ); - const hooksJsonCommands = readHooksSessionStartCommands(codexHome); - assert.ok( - hooksJsonCommands.some((cmd) => typeof cmd === 'string' && /gsd-check-update/.test(cmd)), - 'GSD entry must appear in hooks.json SessionStart entries: ' - + JSON.stringify(hooksJsonCommands) - ); - - // The legacy top-level [[hooks]] AoT must NOT coexist with the namespaced - // form after migration. parseTomlToObject distinguishes via Array.isArray. - assert.ok( - !Array.isArray(parsed.hooks) || parsed.hooks.length === 0, - 'no top-level [[hooks]] AoT entries may remain after legacy migration: ' - + JSON.stringify(parsed.hooks) - ); - - // No duplicate gsd-check-update entries — exactly one managed entry. - const gsdEntries = hooksJsonCommands.filter((cmd) => typeof cmd === 'string' && /gsd-check-update/.test(cmd)); - assert.equal(gsdEntries.length, 1, - 'exactly one gsd-check-update entry after migration, got: ' + gsdEntries.length); - }); -}); - -describe('#2760 CR4 finding 3 / #3245 — parseTomlToObject handles edge-case value types (floats accepted; dates/trailing-garbage rejected)', () => { - // #3245 inverts the float-rejection requirement: Codex CLI's serde schema - // requires f64 for tool_timeout_sec/startup_timeout_sec, so GSD's parser - // must now ACCEPT floats. The original guard (from #2760 CR4 finding 3) was - // "don't silently truncate 0.5 to integer 0" — that goal is still met - // because we parse the full float as a JS Number (not truncate to prefix). - test('accepts TOML floats (timeout = 0.5) — #3245 fix', () => { - const content = [ - '[server]', - 'timeout = 0.5', - '', - ].join('\n'); - const parsed = parseTomlToObject(content); - assert.strictEqual(parsed.server.timeout, 0.5, - 'float values must be accepted as JS Number (not truncated to 0) — #3245'); - }); - - test('rejects date values (created = 1979-05-27)', () => { - const content = [ - '[meta]', - 'created = 1979-05-27', - '', - ].join('\n'); - assert.throws( - () => parseTomlToObject(content), - /unsupported TOML value|trailing bytes/, - 'date values must be rejected, not silently truncated' - ); - }); - - test('rejects trailing garbage after a string value (key = "x" junk)', () => { - const content = [ - '[section]', - 'key = "x" junk', - '', - ].join('\n'); - assert.throws( - () => parseTomlToObject(content), - /trailing bytes/, - 'trailing bytes after a complete value must be rejected' - ); - }); - - test('accepts trailing whitespace and # comment after a value', () => { - const content = [ - '[section]', - 'key = "x" # an inline comment', - 'flag = true', - 'count = 7 ', - '', - ].join('\n'); - const parsed = parseTomlToObject(content); - assert.equal(parsed.section.key, 'x'); - assert.equal(parsed.section.flag, true); - assert.equal(parsed.section.count, 7); - }); -}); - -// concurrency: false — see the fix-3 suite above for the same rationale. -describe('#2760 CR4 finding 1 — atomicWriteFileSync failure aborts install (post-write fatal)', { concurrency: false }, () => { - let tmpDir; - let codexHome; - let originalRenameSync; - let originalConsoleLog; - let consoleOutput; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2760-cr4-f1-')); - codexHome = path.join(tmpDir, 'codex-home'); - originalRenameSync = fs.renameSync; - originalConsoleLog = console.log; - consoleOutput = []; - console.log = (...args) => { consoleOutput.push(args.join(' ')); }; - }); - - afterEach(() => { - fs.renameSync = originalRenameSync; - console.log = originalConsoleLog; - cleanup(tmpDir); - }); - - test('install throws and never prints "Done!" when atomicWriteFileSync fails on configPath', () => { - const preInstall = [ - '# user file', - '[model]', - 'name = "o3"', - '', - ].join('\n'); - writeCodexConfig(codexHome, preInstall); - - const configPath = path.join(codexHome, 'config.toml'); - // Only fault the hook-block atomic rename — earlier writes to config.toml - // happen via mergeCodexConfig (agent-block emit). We want to exercise the - // post-write Codex install branch specifically. Detect by reading the temp - // file's contents and only faulting when the hook block is present. - fs.renameSync = (src, dst) => { - if (dst === configPath) { - let isHookWrite = false; - try { - const data = fs.readFileSync(src, 'utf8'); - isHookWrite = /GSD codex_hooks ownership/.test(data); - } catch (_) { /* ignore */ } - if (isHookWrite) { - throw new Error('simulated rename failure'); - } - } - return originalRenameSync(src, dst); - }; - - let threw = false; - let thrownMessage = ''; - try { - runCodexInstall(codexHome); - } catch (e) { - threw = true; - thrownMessage = e.message; - } - - assert.equal(threw, true, 'install must throw when atomic write fails'); - assert.match( - thrownMessage, - /post-write Codex install failed/, - 'thrown error must use the post-write prefix so the outer catch treats it as fatal' - ); - - // Critical: install must NOT have printed any "Done!" success banner. - const printedDone = consoleOutput.some( - (line) => typeof line === 'string' && /Done!/i.test(line) - ); - assert.equal(printedDone, false, - 'install must NOT print "Done!" after a write failure: ' + JSON.stringify(consoleOutput.filter((l) => /Done|✓/.test(l)))); - - // And the user's pre-install bytes are intact (snapshot restore). - const after = fs.readFileSync(configPath, 'utf8'); - assert.equal(after, preInstall, 'pre-install bytes preserved after fatal abort'); - }); -}); - -// concurrency: false — patches module.exports.__codexSchemaValidator, a -// shared test seam. Serializing prevents stray patches from sibling tests. -describe('#2760 CR5 finding 1 — pre-write failures abort install (outer catch fatal)', { concurrency: false }, () => { - let tmpDir; - let codexHome; - let originalConsoleLog; - let consoleOutput; - const installModule = require('../bin/install.js'); - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2760-cr5-f1-')); - codexHome = path.join(tmpDir, 'codex-home'); - originalConsoleLog = console.log; - consoleOutput = []; - console.log = (...args) => { consoleOutput.push(args.join(' ')); }; - }); - - afterEach(() => { - console.log = originalConsoleLog; - delete installModule.__codexSchemaValidator; - cleanup(tmpDir); - }); - - test('pre-write throw (validator throws, not returns {ok:false}) is fatal and restores snapshot', () => { - // A validator that THROWS (vs returning {ok:false}) bypasses the - // validation branch and exits the inner try via the catch at the outer - // level. Pre-CR5, that catch downgraded to console.warn and let the - // install print "Done!" with no Codex hooks. Post-CR5 it must rethrow. - const preInstall = [ - '# user file', - '[model]', - 'name = "o3"', - '', - ].join('\n'); - writeCodexConfig(codexHome, preInstall); - - installModule.__codexSchemaValidator = () => { - throw new Error('synthetic validator-throw simulating a pre-write helper failure'); - }; - - let threw = false; - let thrownMsg = ''; - try { - runCodexInstall(codexHome); - } catch (e) { - threw = true; - thrownMsg = e.message; - } - - assert.equal(threw, true, - 'install must rethrow when a pre-write step throws (CR5 finding 1)'); - assert.match(thrownMsg, /pre-write|synthetic validator-throw/, - 'thrown error must surface the pre-write wrapper or original message: ' + thrownMsg); - - const printedDone = consoleOutput.some( - (line) => typeof line === 'string' && /Done!/i.test(line) - ); - assert.equal(printedDone, false, - 'install must NOT print "Done!" after a pre-write failure: ' + - JSON.stringify(consoleOutput.filter((l) => /Done|✓/.test(l)))); - - // Pre-install bytes intact (snapshot restored). - const after = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8'); - assert.equal(after, preInstall, - 'pre-install bytes must survive a pre-write helper throw'); - }); -}); - -describe('#2760 CR5 finding 2 — parseTomlToObject rejects duplicate keys and shape-mismatched headers', () => { - test('rejects duplicate scalar key in same table ([a]\\nx=1\\nx=2)', () => { - const content = [ - '[a]', - 'x = 1', - 'x = 2', - '', - ].join('\n'); - assert.throws( - () => parseTomlToObject(content), - /duplicate key/, - 'real TOML 1.0 rejects duplicate keys in the same table' - ); - }); - - test('rejects duplicate scalar key in root table', () => { - const content = [ - 'x = 1', - 'x = 2', - '', - ].join('\n'); - assert.throws( - () => parseTomlToObject(content), - /duplicate key/, - 'duplicate root-table keys must be rejected' - ); - }); - - test('rejects re-declared [a] table header ([a] then [a] again)', () => { - const content = [ - '[a]', - 'x = 1', - '', - '[a]', - 'y = 2', - '', - ].join('\n'); - assert.throws( - () => parseTomlToObject(content), - /duplicate or shape-mismatched table header/, - 'real TOML 1.0 rejects re-declaring the same [a] header twice' - ); - }); - - test('rejects [[arr]] then [arr] for same path (array-of-tables → table)', () => { - const content = [ - '[[arr]]', - 'x = 1', - '', - '[arr]', - 'y = 2', - '', - ].join('\n'); - assert.throws( - () => parseTomlToObject(content), - /duplicate or shape-mismatched table header/, - 'cannot redeclare an array-of-tables path as a plain table' - ); - }); - - test('accepts repeated [[arr]] (genuine array-of-tables)', () => { - const content = [ - '[[arr]]', - 'x = 1', - '', - '[[arr]]', - 'x = 2', - '', - ].join('\n'); - const parsed = parseTomlToObject(content); - assert.ok(Array.isArray(parsed.arr)); - assert.strictEqual(parsed.arr.length, 2); - assert.strictEqual(parsed.arr[0].x, 1); - assert.strictEqual(parsed.arr[1].x, 2); - }); - - test('accepts disjoint nested headers (not duplicates)', () => { - const content = [ - '[a.b]', - 'x = 1', - '', - '[a.c]', - 'y = 2', - '', - ].join('\n'); - const parsed = parseTomlToObject(content); - assert.strictEqual(parsed.a.b.x, 1); - assert.strictEqual(parsed.a.c.y, 2); - }); -}); - -// concurrency: false — drives the same install pipeline as the other f-suites. -describe('#2760 CR5 finding 3 — migration emits namespaced AoT (no flat/namespaced mixing)', { concurrency: false }, () => { - let tmpDir; - let codexHome; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2760-cr5-f3-')); - codexHome = path.join(tmpDir, 'codex-home'); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('user has [[hooks.AfterTool]] AND legacy [hooks.SessionStart] → post-install both namespaced, no flat AoT', () => { - // Reproduces the mixed-form scenario from finding 3: - // - User pre-config has both a namespaced AoT entry [[hooks.AfterTool]] - // AND a legacy single-bracket [hooks.SessionStart]. - // - Pre-CR5 migration converts the legacy section to flat [[hooks]] - // with event="SessionStart", leaving a mixed flat+namespaced layout. - // - Post-CR5 migration emits [[hooks.SessionStart]] directly so both - // of the user's hooks coexist in the namespaced shape, and the - // GSD-managed entry converges on namespaced too. - const userPlusLegacy = [ - '[[hooks.AfterTool]]', - 'command = "x"', - '', - '[hooks.SessionStart]', - 'command = "y"', - '', - ].join('\n'); - writeCodexConfig(codexHome, userPlusLegacy); - - runCodexInstall(codexHome); - const after = readCodexConfig(codexHome); - const parsed = parseTomlToObject(after); - - // The pre-existing [[hooks.AfterTool]] entry is preserved. - assert.ok( - parsed.hooks && Array.isArray(parsed.hooks.AfterTool), - 'pre-existing [[hooks.AfterTool]] must remain a namespaced AoT array' - ); - // AfterTool was in [[hooks.AfterTool]] with command at event-entry level - // (pre-#2773 stale namespaced AoT shape). Migration now promotes these to - // the two-level nested form, so every entry must have a .hooks sub-array. - assert.ok( - parsed.hooks.AfterTool.every((e) => Array.isArray(e.hooks)), - 'every AfterTool entry must use nested [[hooks.AfterTool.hooks]] handlers after migration' - ); - const afterToolCommands = parsed.hooks.AfterTool.flatMap((e) => - e.hooks.map((h) => h.command).filter(Boolean) - ); - assert.ok( - afterToolCommands.includes('x'), - 'user AfterTool entry must be preserved: ' + JSON.stringify(afterToolCommands) - ); - - // The migrated SessionStart entry is now namespaced AoT with nested .hooks sub-table. - assert.ok( - parsed.hooks && Array.isArray(parsed.hooks.SessionStart), - 'migrated SessionStart must be namespaced AoT (not flat [[hooks]])' - ); - // After migration, [hooks.SessionStart] map-format is promoted to nested AoT. - // Command lives in [[hooks.SessionStart.hooks]][0].command (nested schema). - assert.ok( - parsed.hooks.SessionStart.every((e) => Array.isArray(e.hooks)), - 'every SessionStart entry must use nested [[hooks.SessionStart.hooks]] handlers after migration' - ); - const ssCommands = parsed.hooks.SessionStart.flatMap((e) => - e.hooks.map((h) => h.command).filter(Boolean) - ); - assert.ok( - ssCommands.includes('y'), - 'user SessionStart command "y" must be preserved in namespaced array: ' + - JSON.stringify(ssCommands) - ); - // GSD's managed gsd-check-update entry also lives in the namespaced array. - const hooksJsonCommands = readHooksSessionStartCommands(codexHome); - assert.ok( - hooksJsonCommands.some((cmd) => typeof cmd === 'string' && /gsd-check-update/.test(cmd)), - 'managed gsd-check-update entry must appear in hooks.json SessionStart entries: ' + - JSON.stringify(hooksJsonCommands) - ); - - // No flat top-level [[hooks]] AoT may remain. - assert.ok( - !Array.isArray(parsed.hooks) || parsed.hooks.length === 0, - 'no flat top-level [[hooks]] AoT entries may remain after migration: ' + - JSON.stringify(parsed.hooks) - ); - - // No synthetic event field on the migrated SessionStart entries — the - // namespace IS the event. - for (const entry of parsed.hooks.SessionStart) { - assert.equal(entry.event, undefined, - 'no synthetic event field — namespace [[hooks.SessionStart]] encodes the event: ' + - JSON.stringify(entry)); - } - }); -}); - }); -} - - -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/bug-279-codex-agent-mapping.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:bug-279-codex-agent-mapping (consolidation epic #1969 B1 #1970)", () => { -'use strict'; -// allow-test-rule: source-text-is-the-product [adapter header contract in bin/install.js] (see #279) - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); - -const INSTALL_JS = path.join(__dirname, '..', 'bin', 'install.js'); -const src = fs.readFileSync(INSTALL_JS, 'utf8'); - -describe('bug #279: Codex adapter documents Agent() and deferred tool discovery', () => { - test('adapter mapping section includes explicit Agent(...) -> spawn_agent mapping', () => { - // allow-test-rule: source-text-is-the-product [adapter header contract in bin/install.js] (see #279) - assert.ok( - /Task\(subagent_type="X", prompt="Y"\).*spawn_agent\(agent_type="X", message="Y"\)/.test(src) && // allow-test-rule: source-text-is-the-product [adapter header contract in bin/install.js] (see #279) - /Agent\(subagent_type="X", prompt="Y"\).*spawn_agent\(agent_type="X", message="Y"\)/.test(src), // allow-test-rule: source-text-is-the-product [adapter header contract in bin/install.js] (see #279) - 'Codex adapter must explicitly map both Task(...) and Agent(...) to spawn_agent', - ); - }); - - test('adapter includes deferred tool_search discovery guidance before inline fallback', () => { - // allow-test-rule: source-text-is-the-product [adapter header contract in bin/install.js] (see #279) - assert.ok( - src.includes('deferred') && src.includes('tool_search') && src.includes('spawn_agent'), // allow-test-rule: source-text-is-the-product [adapter header contract in bin/install.js] (see #279) - 'Codex adapter must instruct deferred tool discovery via tool_search before deciding to run inline', - ); - }); -}); - }); -} - - -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/bug-3017-codex-hook-absolute-node.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:bug-3017-codex-hook-absolute-node (consolidation epic #1969 B1 #1970)", () => { -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -/** - * Bug #3017: Codex SessionStart hook still emits bare `node` after #3002. - * - * PR #3002 fixed #2979 for settings.json-based managed JS hooks (Claude - * Code, Gemini, Antigravity) by routing through buildHookCommand() → - * resolveNodeRunner(), which emits the absolute Node binary path. But the - * Codex install path writes its SessionStart hook directly into a - * config.toml string, bypassing both helpers: - * - * command = "node ${updateCheckScript}" - * - * Under a GUI/minimal PATH (`/usr/bin:/bin:/usr/sbin:/sbin`) where node - * is not resolvable, the hook fails with `/bin/sh: node: command not - * found` (exit 127). The same failure mode #2979 was meant to fix — - * just on the codex toml branch instead of the settings.json branch. - * - * The fix exposes two pure helpers and tests them as typed records, - * not by grepping install.js content: - * - * buildCodexHookBlock(targetDir, { absoluteRunner }) → toml string - * - emits `command = " "` so the - * hook resolves under minimal PATH. - * - returns null when absoluteRunner is null (caller skips with warn, - * matching settings.json branch behavior). - * - * rewriteLegacyCodexHookBlock(tomlContent, absoluteRunner) → { content, changed } - * - rewrites an existing bare-node managed-hook command on reinstall - * (matches the rewriteLegacyManagedNodeHookCommands shape from #3002). - */ - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const path = require('node:path'); - -const HOOKS_SURFACE = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'runtime-hooks-surface.cjs')); -const projection = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'shell-command-projection.cjs')); -const { buildCodexHookBlock, rewriteLegacyCodexHookBlock, resolveNodeRunner } = HOOKS_SURFACE; -const { projectCodexHookTomlCommand } = projection; - -/** - * Parse the toml hook block into a typed record so tests can assert on - * the structured shape (what's the runner, what's the hook path, what's - * the type) rather than substring-matching the toml text. - */ -function parseCodexHookBlock(block) { - if (!block) return { ok: false, reason: 'empty' }; - // The block always carries the "# GSD Hooks" marker, the AoT tables, - // a type=command, and a command=" " line. - const hasMarker = /^# GSD Hooks$/m.test(block); - const hasEvent = /^\[\[hooks\.SessionStart\]\]$/m.test(block); - const hasHandler = /^\[\[hooks\.SessionStart\.hooks\]\]$/m.test(block); - const typeMatch = block.match(/^type\s*=\s*"([^"]+)"$/m); - // command = " " — runner may itself be a quoted absolute path. - // Match the whole RHS as one toml double-quoted string, then split into runner + hookpath. - const cmdLine = block.match(/^command\s*=\s*"((?:[^"\\]|\\.)*)"$/m); - if (!cmdLine) return { ok: false, reason: 'no command line' }; - const cmdValue = cmdLine[1]; - // Inside the command value, the runner is either a quoted string (escaped \" in toml) - // or a bare token, followed by a space and the hook path (quoted). - // toml escapes interior " as \", so the cmdValue contains literal \" sequences. - const cmdParsed = cmdValue.match(/^(\\".+?\\"|node|bash|\S+)\s+\\"([^\\]+)\\"\s*$/); - return { - ok: true, - hasMarker, - hasEvent, - hasHandler, - type: typeMatch ? typeMatch[1] : null, - command: cmdValue, - runner: cmdParsed ? cmdParsed[1] : null, - hookPath: cmdParsed ? cmdParsed[2] : null, - }; -} - -// Strip the toml-escape (\") and JSON-quote (") layers from the parsed -// runner token to compare against the raw absolute path the caller -// supplied. parsed.runner round-trips through TWO escape layers: -// 1. JSON.stringify in resolveNodeRunner adds outer "..." quotes -// 2. toml escapes the interior " to \" inside the command field -// After both, parsed.runner ends in `\"` and starts with `\"`. -function unescapeRunner(token) { - if (!token) return token; - let t = token.replace(/^\\"/, '').replace(/\\"$/, ''); - if (t.startsWith('"') && t.endsWith('"')) t = t.slice(1, -1); - return t; -} - -describe('Bug #3017 / #3440: Codex hook projection seam', () => { - test('projectCodexHookTomlCommand renders escaped command value from shared projection module', () => { - const commandValue = projectCodexHookTomlCommand({ - absoluteRunner: '"/usr/local/bin/node"', - scriptPath: '/tmp/codex-test/.codex/hooks/gsd-check-update.js', - platform: 'linux', - }); - assert.equal( - commandValue, - '\\"/usr/local/bin/node\\" \\"/tmp/codex-test/.codex/hooks/gsd-check-update.js\\"', - ); - }); -}); - -describe('Bug #3017: buildCodexHookBlock emits absolute node runner', () => { - test('exported as a function', () => { - assert.equal(typeof buildCodexHookBlock, 'function'); - }); - - test('emits the EXACT absolute node runner the caller supplied (#3022 CR)', () => { - const targetDir = '/tmp/codex-test/.codex'; - const expectedRunnerPath = '/usr/local/bin/node'; - const absoluteRunner = `"${expectedRunnerPath}"`; - const block = buildCodexHookBlock(targetDir, { absoluteRunner }); - const parsed = parseCodexHookBlock(block); - assert.equal(parsed.ok, true, `parse failed: ${block}`); - assert.equal(parsed.hasMarker, true, '# GSD Hooks marker present'); - assert.equal(parsed.hasEvent, true, '[[hooks.SessionStart]] AoT entry present'); - assert.equal(parsed.hasHandler, true, '[[hooks.SessionStart.hooks]] handler entry present'); - assert.equal(parsed.type, 'command', 'handler is type=command'); - // Strict: parsed runner must match the supplied absolute path EXACTLY - // (after stripping toml/JSON escape layers). A loose substring like - // '/node' would let an unrelated absolute token containing '/node' - // pass — e.g. '/Users/x/notnode/foo'. - assert.equal(unescapeRunner(parsed.runner), expectedRunnerPath, - `parsed runner must equal supplied absolute path: got ${parsed.runner}, want ${expectedRunnerPath}`); - // On Windows, path.resolve prepends the current drive letter ("D:") to - // the POSIX-shaped fixture path. Accept either form. - const expectedHookSuffix = '/tmp/codex-test/.codex/hooks/gsd-check-update.js'; - assert.ok( - parsed.hookPath === expectedHookSuffix || - parsed.hookPath.replace(/^[A-Za-z]:/, '') === expectedHookSuffix, - `hook path equality, got: ${parsed.hookPath}, want suffix: ${expectedHookSuffix}`, - ); - }); - - test('returns null when absoluteRunner is null (caller skips registration)', () => { - const block = buildCodexHookBlock('/tmp/x/.codex', { absoluteRunner: null }); - assert.equal(block, null, - 'must return null on missing runner so caller can warn-and-skip instead of writing a broken hook'); - }); - - test('integrates with resolveNodeRunner() in the live process — runner equals resolved node runner (#3022 CR)', () => { - const runner = resolveNodeRunner(); - assert.ok(runner, 'resolveNodeRunner returns a usable value in this test env'); - const block = buildCodexHookBlock('/tmp/x/.codex', { absoluteRunner: runner }); - const parsed = parseCodexHookBlock(block); - assert.equal(parsed.ok, true); - // Strict canonical-runner equality: the parsed runner (after stripping - // toml + JSON escape layers) must be exactly the normalized runner that - // resolveNodeRunner selected. Homebrew Cellar execPath values intentionally - // normalize to the stable Homebrew symlink (#3181). - const expected = JSON.parse(runner); - assert.equal(unescapeRunner(parsed.runner), expected, - `parsed runner must equal resolveNodeRunner(), got: ${parsed.runner}, want: ${expected}`); - }); -}); - -describe('Bug #3017: rewriteLegacyCodexHookBlock migrates bare-node on reinstall', () => { - test('exported as a function', () => { - assert.equal(typeof rewriteLegacyCodexHookBlock, 'function'); - }); - - test('rewrites a bare-node managed-hook command to the absolute runner', () => { - const before = [ - '[model]', - 'name = "o3"', - '', - '# GSD Hooks', - '[[hooks.SessionStart]]', - '', - '[[hooks.SessionStart.hooks]]', - 'type = "command"', - 'command = "node /Users/x/.codex/hooks/gsd-check-update.js"', - '', - ].join('\n'); - const expectedRunnerPath = '/usr/local/bin/node'; - const runner = `"${expectedRunnerPath}"`; - const result = rewriteLegacyCodexHookBlock(before, runner); - assert.equal(result.changed, true, 'must report change=true'); - // The migrated command must use the EXACT absolute runner the caller - // supplied (#3022 CR — was previously asserting a loose '/node' - // substring which let unrelated absolute paths pass). - const parsed = parseCodexHookBlock(result.content); - assert.equal(parsed.ok, true); - assert.equal(unescapeRunner(parsed.runner), expectedRunnerPath, - `runner must equal supplied absolute path: ${parsed.runner}`); - assert.equal(parsed.hookPath, '/Users/x/.codex/hooks/gsd-check-update.js'); - // Non-GSD content (the [model] block) must be preserved verbatim. - assert.ok(result.content.includes('[model]')); - assert.ok(result.content.includes('name = "o3"')); - }); - - test('decodes TOML-escaped quoted script paths before projection', () => { - const before = [ - '# GSD Hooks', - '[[hooks.SessionStart]]', - '', - '[[hooks.SessionStart.hooks]]', - 'type = "command"', - 'command = "node \\"C:\\\\Users\\\\x\\\\.codex\\\\hooks\\\\gsd-check-update.js\\""', - '', - ].join('\n'); - const runner = '"/usr/local/bin/node"'; - const result = rewriteLegacyCodexHookBlock(before, runner, { platform: 'win32' }); - assert.equal(result.changed, true); - const parsed = parseCodexHookBlock(result.content); - assert.equal(parsed.ok, true, 'hook block must parse correctly'); - const expected = projectCodexHookTomlCommand({ - absoluteRunner: runner, - scriptPath: 'C:\\Users\\x\\.codex\\hooks\\gsd-check-update.js', - platform: 'win32', - }); - assert.equal(parsed.command, expected, - 'rewritten command must project from decoded Windows path (not TOML-escaped token text)'); - assert.equal(unescapeRunner(parsed.runner), '/usr/local/bin/node', - 'runner must equal supplied absolute path'); - assert.equal(parsed.hookPath, 'C:/Users/x/.codex/hooks/gsd-check-update.js', - 'hook path must equal decoded Windows path after projection normalization'); - }); - - test('does NOT touch a managed-hook entry that already uses an absolute runner', () => { - const already = [ - '# GSD Hooks', - '[[hooks.SessionStart]]', - '', - '[[hooks.SessionStart.hooks]]', - 'type = "command"', - 'command = "\\"/usr/local/bin/node\\" /Users/x/.codex/hooks/gsd-check-update.js"', - '', - ].join('\n'); - const result = rewriteLegacyCodexHookBlock(already, '"/usr/local/bin/node"'); - assert.equal(result.changed, false); - assert.equal(result.content, already); - }); - - test('does NOT touch user-authored bare-node hooks (filename not in managed allowlist)', () => { - const userOwned = [ - '[[hooks.SessionStart]]', - '', - '[[hooks.SessionStart.hooks]]', - 'type = "command"', - 'command = "node /home/me/my-custom-codex-hook.js"', - '', - ].join('\n'); - const result = rewriteLegacyCodexHookBlock(userOwned, '"/usr/local/bin/node"'); - assert.equal(result.changed, false, - 'user-authored hooks must be left alone; only managed gsd-* hooks are migrated'); - assert.equal(result.content, userOwned); - }); - - test('returns content unchanged when absoluteRunner is null', () => { - const before = 'command = "node /path/to/gsd-check-update.js"'; - const result = rewriteLegacyCodexHookBlock(before, null); - assert.equal(result.changed, false); - assert.equal(result.content, before); - }); -}); - }); -} - - -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/bug-3018-codex-discuss-fallback.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:bug-3018-codex-discuss-fallback (consolidation epic #1969 B1 #1970)", () => { -/** - * Regression test for bug #3018. - * - * @jon-hendry: running `$gsd-discuss-phase 81` in Codex Default mode (where - * `request_user_input` is rejected) caused the agent to pick "reasonable - * defaults" and proceed straight into writing CONTEXT.md / DISCUSSION-LOG.md - * checkpoints — without ever surfacing the questions to the user. The - * generated Codex skill adapter explicitly told it to do that: - * - * "When `request_user_input` is rejected (Execute mode), present a - * plain-text numbered list and pick a reasonable default." - * - * Discuss-mode is the wrong place for that fallback. The contract should be: - * stop, render the questions as plain text, wait for the user's answer. - * Defaults may only be picked when the user has authorized non-interactive - * mode (--auto / --all) or has explicitly approved them. - * - * Test design (#3027 CR follow-up): instead of grepping the prose with - * regex, parse the fallback section into a typed semantic-flag record and - * assert on those booleans. This adheres to CONTRIBUTING.md "no-source-grep" - * — the test names a behavioral invariant, the parser walks the prose - * once and exposes the invariants as named flags, and the prose can be - * reworded freely as long as the flags stay true. - */ - -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const path = require('node:path'); - -const INSTALL = require(path.join(__dirname, '..', 'bin', 'install.js')); -const { getCodexSkillAdapterHeader } = INSTALL; -const { tokenizeHeadings } = require('../gsd-core/bin/lib/markdown-sectionizer.cjs'); - -/** - * Extract the "Execute mode fallback" section text from the adapter header. - * Returns null if the section is missing. Section runs from the - * "Execute mode fallback:" label up to the next heading or tag. - */ -function extractExecuteModeFallback(header) { - const label = 'Execute mode fallback:'; - const labelIdx = header.indexOf(label); - if (labelIdx === -1) return null; - const bodyStart = header.indexOf('\n', labelIdx + label.length); - if (bodyStart === -1) return null; - - // End at whichever comes first: the next "## " heading (via the canonical - // heading tokenizer, not an ad-hoc regex) or the closing adapter tag. - const headings = tokenizeHeadings(header).filter((h) => h.level === 2 && h.offset > bodyStart); - const nextHeadingOffset = headings.length > 0 ? headings[0].offset - 1 : Infinity; // -1 for the leading \n - const closeTagIdx = header.indexOf('', bodyStart); - const closeTagOffset = closeTagIdx === -1 ? Infinity : closeTagIdx - 1; // -1 for the leading \n - const bodyEnd = Math.min(nextHeadingOffset, closeTagOffset); - if (bodyEnd === Infinity) return null; - - return header.slice(bodyStart + 1, bodyEnd).trim(); -} - -/** - * Parse the Execute-mode-fallback section into a typed semantic-flag - * record. Each flag answers a single behavioral question that the #3018 - * fix is contractually required to encode in the prose. Tests assert on - * the booleans, not the wording — so the prose can evolve without test - * churn as long as the semantics stay correct. - * - * The flags are derived from a single pass over the section text: each - * one looks for any of a small set of synonym phrases that a correct - * implementation would use. The negative anti-pattern flag - * (`silentlyPicksDefaults`) is the regression guard — the prose under - * #3018 told the agent to "pick a reasonable default" autonomously, - * which is exactly what this fix removes. - */ -function parseExecuteModeFallback(section) { - if (!section || typeof section !== 'string') { - return { - ok: false, - sectionLength: 0, - instructsStop: false, - presentsPlainTextQuestions: false, - namesPermissionPath: false, - forbidsWritingArtifactsBeforeAnswer: false, - silentlyPicksDefaults: false, - }; - } - const lower = section.toLowerCase(); - // (a) STOP/WAIT directive — the agent must halt instead of proceeding. - const instructsStop = /\b(stop|halt|wait)\b/.test(lower); - // (b) Plain-text fallback presentation — the agent must surface the - // questions in some inspectable form (numbered list / plain text). - const presentsPlainTextQuestions = /plain.?text|numbered list/.test(lower); - // (c) Permission path that DOES allow defaults — must name at least - // one (--auto / --all / explicit user approval / autonomous workflow). - const namesPermissionPath = - /--auto|--all/.test(section) || - /explicit(ly)? (approv|authoriz|consent)/i.test(section) || - /user (has )?approv|user (has )?authoriz|user (has )?consent/i.test(section) || - /autonomous (lifecycle|workflow|paths?)/i.test(section); - // (d) Artifact-write ban — the agent must not produce workflow files - // (CONTEXT.md, DISCUSSION-LOG.md, PLAN.md, checkpoints) before the - // user answers or one of the permission-path conditions applies. - // Require BOTH a "do not write" intent AND a named artifact class so - // generic "do not write" prose elsewhere can't satisfy the flag. - const forbidsWriteIntent = /do not write|don'?t write|must not write|shall not write/i.test(section); - const namesArtifactClass = /artifact|checkpoint|context\.md|discussion.?log|plan\.md/i.test(section); - const forbidsWritingArtifactsBeforeAnswer = forbidsWriteIntent && namesArtifactClass; - // Anti-pattern guard — the prose that caused #3018. This MUST be false. - const silentlyPicksDefaults = /pick (a |the )?(reasonable|sensible|sane) default/i.test(section); - return { - ok: true, - sectionLength: section.length, - instructsStop, - presentsPlainTextQuestions, - namesPermissionPath, - forbidsWritingArtifactsBeforeAnswer, - silentlyPicksDefaults, - }; -} - -describe('bug #3018: codex skill adapter encodes the discuss-mode fallback contract', () => { - test('exports the adapter generator', () => { - assert.equal(typeof getCodexSkillAdapterHeader, 'function'); - }); - - test('Execute mode fallback section exists and has content', () => { - const header = getCodexSkillAdapterHeader('gsd-discuss-phase'); - const section = extractExecuteModeFallback(header); - const parsed = parseExecuteModeFallback(section); - assert.equal(parsed.ok, true, `section must parse, got header:\n${header}`); - assert.ok(parsed.sectionLength > 0, 'section must be non-empty'); - }); - - test('fallback instructs STOP/WAIT (not silent continuation)', () => { - const section = extractExecuteModeFallback(getCodexSkillAdapterHeader('gsd-discuss-phase')); - const parsed = parseExecuteModeFallback(section); - assert.equal(parsed.instructsStop, true, - `must instruct stop/halt/wait — section was:\n${section}`); - }); - - test('fallback prescribes plain-text question presentation', () => { - const section = extractExecuteModeFallback(getCodexSkillAdapterHeader('gsd-discuss-phase')); - const parsed = parseExecuteModeFallback(section); - assert.equal(parsed.presentsPlainTextQuestions, true, - `must mention plain-text / numbered-list presentation — section was:\n${section}`); - }); - - test('fallback names a permission path under which defaults ARE allowed (--auto / --all / explicit approval / autonomous)', () => { - const section = extractExecuteModeFallback(getCodexSkillAdapterHeader('gsd-discuss-phase')); - const parsed = parseExecuteModeFallback(section); - assert.equal(parsed.namesPermissionPath, true, - `must name at least one permission path — section was:\n${section}`); - }); - - test('fallback forbids writing workflow artifacts before user answers', () => { - const section = extractExecuteModeFallback(getCodexSkillAdapterHeader('gsd-discuss-phase')); - const parsed = parseExecuteModeFallback(section); - assert.equal(parsed.forbidsWritingArtifactsBeforeAnswer, true, - `must encode write-ban + named artifact class — section was:\n${section}`); - }); - - test('fallback does NOT contain the #3018 anti-pattern ("pick a reasonable default")', () => { - const section = extractExecuteModeFallback(getCodexSkillAdapterHeader('gsd-discuss-phase')); - const parsed = parseExecuteModeFallback(section); - assert.equal(parsed.silentlyPicksDefaults, false, - `regression — fallback must NOT instruct the agent to pick defaults autonomously, section was:\n${section}`); - }); - - test('all four positive flags + the negative anti-pattern flag — typed-record snapshot', () => { - // Single assertion that the whole semantic record matches the contract. - // If any flag flips, the test fails with a structured diff naming the - // exact invariant that broke. - const section = extractExecuteModeFallback(getCodexSkillAdapterHeader('gsd-discuss-phase')); - const parsed = parseExecuteModeFallback(section); - const semanticContract = { - ok: parsed.ok, - instructsStop: parsed.instructsStop, - presentsPlainTextQuestions: parsed.presentsPlainTextQuestions, - namesPermissionPath: parsed.namesPermissionPath, - forbidsWritingArtifactsBeforeAnswer: parsed.forbidsWritingArtifactsBeforeAnswer, - silentlyPicksDefaults: parsed.silentlyPicksDefaults, - }; - assert.deepStrictEqual(semanticContract, { - ok: true, - instructsStop: true, - presentsPlainTextQuestions: true, - namesPermissionPath: true, - forbidsWritingArtifactsBeforeAnswer: true, - silentlyPicksDefaults: false, - }, `discuss-mode fallback contract violated — section was:\n${section}`); - }); -}); - }); -} - - -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/bug-3245-codex-toml-floats.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:bug-3245-codex-toml-floats (consolidation epic #1969 B1 #1970)", () => { -/** - * Regression: issue #3245 — Codex install rejects valid TOML floats. - * - * Two defects, two fixes: - * - * Defect 1 — parseTomlValue rejects TOML floats (e.g. tool_timeout_sec = 20.0). - * Codex CLI's serde schema requires f64 for tool_timeout_sec / startup_timeout_sec - * (integers fail with "invalid type: integer"). GSD's strict-integer-only parser - * was the inverse of what Codex requires — any float triggers the rejection branch. - * Fix: extend parseTomlValue to accept TOML 1.0 float literals and return them as - * JS Number. The merged config.toml preserves the float form verbatim so - * round-trip writes don't coerce 20.0 → 20. - * - * Defect 2 — Partial rollback leaves install in hybrid state. - * restoreCodexSnapshot only knew about config.toml, but skills/, agents/, and VERSION - * are written earlier in the install sequence. A post-install validation failure - * aborts with new agent text on disk, config.toml reverted, and .tmp files - * potentially orphaned. - * Fix: capture pre-install state of skills/, agents/, and VERSION before any - * Codex-specific mutation, and extend the rollback to cover all of them. - */ - -// GSD_TEST_MODE must be set before require('../bin/install.js') so the module -// skips the main CLI entry point and exports its internals. -const previousGsdTestMode = process.env.GSD_TEST_MODE; -process.env.GSD_TEST_MODE = '1'; - -const { test, describe, before, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); -const os = require('os'); -const { runNode } = require('./helpers/process-seam.cjs'); -const { throwIfFailed } = require('./helpers/git-fixture.cjs'); -const { cleanup } = require('./helpers.cjs'); - -const { parseTomlToObject, validateCodexConfigSchema, install } = require('../bin/install.js'); -const installModule = require('../bin/install.js'); - -if (previousGsdTestMode === undefined) { - delete process.env.GSD_TEST_MODE; -} else { - process.env.GSD_TEST_MODE = previousGsdTestMode; -} - -// Ensure hooks/dist/ is populated — mirrors the pattern used by codex-config.test.cjs. -const HOOKS_DIST = path.join(__dirname, '..', 'hooks', 'dist'); -const BUILD_HOOKS_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); -// scripts/build-hooks.js copies pre-built hook files into hooks/dist and -// syntax-checks them with vm — it does not compile/bundle anything. See -// tests/helpers/timeouts.cjs for the class-norm justification. -const { BUILD_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); -before(() => { - if (!fs.existsSync(HOOKS_DIST) || fs.readdirSync(HOOKS_DIST).length === 0) { - throwIfFailed( - runNode([BUILD_HOOKS_SCRIPT], { timeoutMs: BUILD_TIMEOUT_MS }), - `node ${BUILD_HOOKS_SCRIPT}`, - ); - } -}); - -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'); - } finally { - process.chdir(previousCwd); - if (previousCodexHome === undefined) { - delete process.env.CODEX_HOME; - } 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; - } -} - -function writeCodexConfig(codexHome, content) { - fs.mkdirSync(codexHome, { recursive: true }); - fs.writeFileSync(path.join(codexHome, 'config.toml'), content, 'utf8'); -} - -// --------------------------------------------------------------------------- -// Defect 1 — parseTomlValue must accept TOML floats -// --------------------------------------------------------------------------- - -describe('#3245 — parseTomlToObject accepts TOML floats', () => { - test('parses bare decimal float (20.0)', () => { - const content = [ - 'tool_timeout_sec = 20.0', - '', - ].join('\n'); - const parsed = parseTomlToObject(content); - assert.strictEqual(typeof parsed.tool_timeout_sec, 'number', - 'tool_timeout_sec should be a JS number'); - assert.strictEqual(parsed.tool_timeout_sec, 20.0, - 'value must equal 20.0'); - }); - - test('parses startup_timeout_sec = 60.0', () => { - const content = [ - 'startup_timeout_sec = 60.0', - '', - ].join('\n'); - const parsed = parseTomlToObject(content); - assert.strictEqual(parsed.startup_timeout_sec, 60.0); - }); - - test('parses positive exponent notation (1e10)', () => { - const content = [ - 'x = 1e10', - '', - ].join('\n'); - const parsed = parseTomlToObject(content); - assert.strictEqual(parsed.x, 1e10); - }); - - test('parses negative exponent (1.5e-3)', () => { - const content = [ - 'x = 1.5e-3', - '', - ].join('\n'); - const parsed = parseTomlToObject(content); - assert.ok(Math.abs(parsed.x - 1.5e-3) < 1e-15, 'must be approximately 1.5e-3'); - }); - - test('parses signed positive float (+1.0)', () => { - const content = [ - 'x = +1.0', - '', - ].join('\n'); - const parsed = parseTomlToObject(content); - assert.strictEqual(parsed.x, 1.0); - }); - - test('parses signed negative float (-0.5)', () => { - const content = [ - 'x = -0.5', - '', - ].join('\n'); - const parsed = parseTomlToObject(content); - assert.strictEqual(parsed.x, -0.5); - }); - - test('parses float with underscore separators (1_000.0)', () => { - const content = [ - 'x = 1_000.0', - '', - ].join('\n'); - const parsed = parseTomlToObject(content); - assert.strictEqual(parsed.x, 1000.0); - }); - - test('integer (no decimal) still parses as integer', () => { - const content = [ - 'x = 42', - '', - ].join('\n'); - const parsed = parseTomlToObject(content); - assert.strictEqual(parsed.x, 42); - }); - - test('still rejects bare date (1979-05-27)', () => { - const content = [ - 'x = 1979-05-27', - '', - ].join('\n'); - assert.throws( - () => parseTomlToObject(content), - /unsupported TOML value/, - 'date literals must remain unsupported' - ); - }); - - test('still rejects bare time (07:32:00)', () => { - const content = [ - 'x = 07:32:00', - '', - ].join('\n'); - // With leading-zero rejection (CR4 fix) the parser stops at `0`, and - // `7:32:00` is "trailing bytes". Either error form is acceptable — the - // key invariant is that time literals are never silently accepted. - assert.throws( - () => parseTomlToObject(content), - /unsupported TOML value|trailing bytes/, - 'time literals must remain unsupported' - ); - }); - - test('still rejects hex literal (0x1A)', () => { - const content = [ - 'x = 0x1A', - '', - ].join('\n'); - // 0 is parsed, then 'x1A' is trailing garbage — rejected with "trailing bytes" - // or "unsupported value" depending on where the parser catches it. - assert.throws( - () => parseTomlToObject(content), - /trailing bytes|unsupported (TOML value|value)/, - 'hex literals must remain unsupported' - ); - }); - - test('validateCodexConfigSchema passes a config with tool_timeout_sec = 20.0', () => { - const content = [ - '[model]', - 'name = "o3"', - '', - 'tool_timeout_sec = 20.0', - 'startup_timeout_sec = 60.0', - '', - ].join('\n'); - const result = validateCodexConfigSchema(content); - assert.strictEqual(result.ok, true, - 'schema validation must pass for a config containing TOML floats: ' + result.reason); - }); -}); - -// --------------------------------------------------------------------------- -// Defect 1 — full install must succeed and preserve float verbatim -// --------------------------------------------------------------------------- - -// concurrency: false — drives the live install pipeline (shared CODEX_HOME env, -// process.chdir). Serialise to prevent stray mutations across parallel siblings. -describe('#3245 — install succeeds with TOML float in pre-existing config', { concurrency: false }, () => { - let tmpDir; - let codexHome; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3245-float-')); - codexHome = path.join(tmpDir, 'codex-home'); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('install completes when config.toml contains tool_timeout_sec = 20.0', () => { - // Floats at the root level (before any table header) — this is where Codex - // CLI reads tool_timeout_sec / startup_timeout_sec according to its serde schema. - const preInstall = [ - 'tool_timeout_sec = 20.0', - 'startup_timeout_sec = 60.0', - '', - '[model]', - 'name = "o3"', - '', - ].join('\n'); - writeCodexConfig(codexHome, preInstall); - - // Must not throw — pre-#3245 this threw "unsupported TOML value … floats … not supported". - assert.doesNotThrow( - () => runCodexInstall(codexHome), - 'install must not throw when config.toml contains TOML floats' - ); - - // The merged config.toml must still contain the float values at root scope. - const after = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8'); - const parsed = parseTomlToObject(after); - assert.strictEqual(parsed.tool_timeout_sec, 20.0, - 'tool_timeout_sec must be preserved as a number after install'); - assert.strictEqual(parsed.startup_timeout_sec, 60.0, - 'startup_timeout_sec must be preserved as a number after install'); - }); - - test('post-install config round-trips tool_timeout_sec as numeric 20', () => { - const preInstall = [ - 'tool_timeout_sec = 20.0', - '', - ].join('\n'); - writeCodexConfig(codexHome, preInstall); - - runCodexInstall(codexHome); - - const after = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8'); - // The value must survive round-trip as a float-compatible representation. - // Parse structurally — don't grep for the literal string "20.0". - const parsed = parseTomlToObject(after); - assert.strictEqual(parsed.tool_timeout_sec, 20, - 'tool_timeout_sec must round-trip as numeric 20 (=== 20.0 in JS)'); - }); -}); - -// --------------------------------------------------------------------------- -// CR round-4 finding — TOML 1.0 disallows leading zeros in integer part -// --------------------------------------------------------------------------- -// -// TOML 1.0 §2: integer literals follow decimal-integer rules, which disallow -// leading zeros except the value `0` itself. `01`, `01.5`, `00e2`, `+01.0` -// are therefore invalid. The `parseTomlValue` integer-part regex is tightened -// from `\d(?:_?\d)*` to `(0|[1-9](?:_?\d)*)`. - -describe('#3245 CR4 — parseTomlValue rejects leading zeros in float integer part', () => { - function parseValue(raw) { - // Wrap in a minimal TOML assignment so parseTomlToObject drives the test. - return parseTomlToObject(`x = ${raw}`).x; - } - - function assertRejects(raw, label) { - let threw = false; - try { parseValue(raw); } catch (_) { threw = true; } - assert.strictEqual(threw, true, `expected rejection for ${label}: ${raw}`); - } - - function assertAccepts(raw, expected, label) { - let val; - let threw = false; - try { val = parseValue(raw); } catch (e) { threw = true; } - assert.strictEqual(threw, false, `expected acceptance for ${label}: ${raw}`); - if (expected !== undefined) { - assert.ok(Math.abs(val - expected) < 1e-12, `${label}: expected ${expected}, got ${val}`); - } - } - - // --- rejection cases: leading zeros in the integer part --- - - test('rejects 01 (leading zero on bare integer)', () => assertRejects('01', '01')); - test('rejects 00 (double-zero bare integer)', () => assertRejects('00', '00')); - test('rejects 01.5 (leading zero before decimal point)', () => assertRejects('01.5', '01.5')); - test('rejects 00.5 (double-zero before decimal)', () => assertRejects('00.5', '00.5')); - test('rejects +01 (leading zero with sign)', () => assertRejects('+01', '+01')); - test('rejects -01 (negative leading zero)', () => assertRejects('-01', '-01')); - test('rejects 00e2 (leading zero with exponent)', () => assertRejects('00e2', '00e2')); - test('rejects +01.0 (leading zero in positive float)', () => assertRejects('+01.0', '+01.0')); - test('rejects -01.0 (leading zero in negative float)', () => assertRejects('-01.0', '-01.0')); - test('rejects 01.5e10 (leading zero, decimal, and exponent)', () => assertRejects('01.5e10', '01.5e10')); - - // --- acceptance cases: valid TOML 1.0 numeric forms --- - - test('accepts 0 (single zero)', () => assertAccepts('0', 0, 'single zero')); - test('accepts 0.5 (zero before decimal)', () => assertAccepts('0.5', 0.5, 'zero.decimal')); - test('accepts 0.0 (zero.zero)', () => assertAccepts('0.0', 0.0, 'zero.zero')); - test('accepts 0e1 (zero with exponent)', () => assertAccepts('0e1', 0, '0e1')); - test('accepts +0.5 (positive zero-decimal)', () => assertAccepts('+0.5', 0.5, '+0.5')); - test('accepts -0.5 (negative zero-decimal)', () => assertAccepts('-0.5', -0.5, '-0.5')); - test('accepts 1 (single non-zero digit)', () => assertAccepts('1', 1, '1')); - test('accepts 12 (two digits)', () => assertAccepts('12', 12, '12')); - test('accepts 1.5 (simple float)', () => assertAccepts('1.5', 1.5, '1.5')); - test('accepts 1_000 (underscored integer)', () => assertAccepts('1_000', 1000, '1_000')); - test('accepts 1_000.5 (underscored float)', () => assertAccepts('1_000.5', 1000.5, '1_000.5')); - test('accepts +1.5 (positive float)', () => assertAccepts('+1.5', 1.5, '+1.5')); - test('accepts -2.0 (negative float)', () => assertAccepts('-2.0', -2.0, '-2.0')); - test('accepts 1.5e-3 (float with negative exponent)', () => assertAccepts('1.5e-3', 1.5e-3, '1.5e-3')); - test('accepts 1.05e10 (fractional part may start with zero)', () => assertAccepts('1.05e10', 1.05e10, '1.05e10')); -}); - -// --------------------------------------------------------------------------- -// Defect 2 — idempotent rollback covers skills, agents, VERSION -// --------------------------------------------------------------------------- - -// concurrency: false — patches module.exports.__codexSchemaValidator and drives -// the install pipeline. Serialise to prevent cross-test pollution. -describe('#3245 — idempotent rollback reverts skills/, agents/, and VERSION', { concurrency: false }, () => { - let tmpDir; - let codexHome; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3245-rollback-')); - codexHome = path.join(tmpDir, 'codex-home'); - }); - - afterEach(() => { - delete installModule.__codexSchemaValidator; - cleanup(tmpDir); - }); - - test('validation failure rolls back skills/, agents/, and VERSION to pre-install state', () => { - // Start from a clean codexHome with no pre-existing GSD content — the dirs - // do not exist yet. After a failed install they must be absent (or contain - // only what was there before, i.e. nothing). - fs.mkdirSync(codexHome, { recursive: true }); - - // Force schema validation to fail so we can observe the rollback without - // needing a genuinely broken config. - installModule.__codexSchemaValidator = () => ({ - ok: false, - reason: 'simulated failure for #3245 rollback test', - }); - - let threw = false; - try { - runCodexInstall(codexHome); - } catch (_) { - threw = true; - } - assert.strictEqual(threw, true, 'install must throw when validation fails'); - - // skills/ — GSD writes gsd-* subdirs here. All must be absent after rollback. - const skillsDir = codexSkillsRoot(codexHome); - if (fs.existsSync(skillsDir)) { - const gsdSkills = fs.readdirSync(skillsDir, { withFileTypes: true }) - .filter(e => e.isDirectory() && e.name.startsWith('gsd-')); - assert.strictEqual( - gsdSkills.length, - 0, - 'rollback must remove all gsd-* skill directories: ' + gsdSkills.map(e => e.name).join(', ') - ); - } - - // agents/ — GSD writes gsd-*.md and gsd-*.toml here. All must be absent. - // Not the shared listAgentFiles() helper: reads the INSTALLED Codex dest - // dir and is .toml-inclusive, so its semantics differ from the source roster. - const agentsDir = path.join(codexHome, 'agents'); - if (fs.existsSync(agentsDir)) { - const gsdAgents = fs.readdirSync(agentsDir) - .filter(f => f.startsWith('gsd-') && (f.endsWith('.md') || f.endsWith('.toml'))); - assert.strictEqual( - gsdAgents.length, - 0, - 'rollback must remove all gsd-* agent files: ' + gsdAgents.join(', ') - ); - } - - // VERSION — GSD writes gsd-core/VERSION. Must be absent (wasn't there before). - const versionPath = path.join(codexHome, 'gsd-core', 'VERSION'); - assert.strictEqual( - fs.existsSync(versionPath), - false, - 'rollback must remove the VERSION file written during install' - ); - }); - - test('rollback is safe when fired before any snapshots were captured (very early failure)', () => { - // If the validator is injected before ANY install writes happen, the rollback - // must not throw — it should be idempotent when nothing was written yet. - fs.mkdirSync(codexHome, { recursive: true }); - - installModule.__codexSchemaValidator = () => ({ - ok: false, - reason: 'very early simulated failure', - }); - - // The install must throw (validation failure), but the rollback that runs - // internally must not throw — it must be idempotent when nothing was written. - let threw = false; - try { - runCodexInstall(codexHome); - } catch (_) { - threw = true; - } - 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 = codexSkillsRoot(codexHome); - const remainingGsdSkills = fs.existsSync(skillsDir) - ? fs.readdirSync(skillsDir, { withFileTypes: true }) - .filter((e) => e.isDirectory() && e.name.startsWith('gsd-')) - .map((e) => e.name) - : []; - assert.deepStrictEqual( - remainingGsdSkills, - [], - 'rollback must remove all gsd-* skill dirs even when fired after minimal writes' - ); - }); - - 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 = 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'); - - installModule.__codexSchemaValidator = () => ({ - ok: false, - reason: 'simulated failure — user skill must survive', - }); - - let threw = false; - try { runCodexInstall(codexHome); } catch (_) { threw = true; } - assert.strictEqual(threw, true, 'expected runCodexInstall to throw under simulated validation failure (user-skill-survives scenario)'); - - assert.strictEqual( - fs.existsSync(path.join(userSkill, 'SKILL.md')), - true, - 'pre-existing non-gsd-* skill must survive rollback' - ); - }); - - test('rollback removes orphaned atomic-write temp files', () => { - // Any .tmp-- files created during aborted atomic writes - // must be cleaned up by the rollback so targetDir is not left with stray - // temp files consuming disk space. - fs.mkdirSync(codexHome, { recursive: true }); - - installModule.__codexSchemaValidator = () => ({ - ok: false, - reason: 'simulated failure for temp-file cleanup test', - }); - - let threw = false; - try { runCodexInstall(codexHome); } catch (_) { threw = true; } - assert.strictEqual(threw, true, 'expected runCodexInstall to throw under simulated validation failure (temp-file cleanup scenario)'); - - // Scan for any *.tmp-* files left in codexHome after rollback. - const tmpPattern = /\.tmp-\d+-\d+$/; - function findTmpFiles(dir) { - if (!fs.existsSync(dir)) return []; - const results = []; - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const full = path.join(dir, entry.name); - if (entry.isDirectory()) { - results.push(...findTmpFiles(full)); - } else if (tmpPattern.test(entry.name)) { - results.push(full); - } - } - return results; - } - const stray = findTmpFiles(codexHome); - assert.strictEqual( - stray.length, - 0, - 'rollback must clean up orphaned atomic-write temp files: ' + stray.join(', ') - ); - }); -}); - }); -} - - -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/bug-3285-codex-hooks-state-allowed.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:bug-3285-codex-hooks-state-allowed (consolidation epic #1969 B1 #1970)", () => { -/** - * Regression: issue #3285 — Codex install fails when config.toml contains - * hooks.state entries. - * - * Root cause: validateCodexConfigSchema walks every `hooks.*` table section - * and asserts array-of-tables (AoT) shape, without distinguishing the - * `hooks.state.*` namespace (Codex-managed per-hook trust persistence, a - * regular table) from `hooks.` (event handlers like SessionStart, - * which DO require AoT shape via [[hooks.SessionStart]]). - * - * Fix: add a carve-out so that any table whose path starts with `hooks.state` - * is validated as a regular table (not AoT). All `hooks.` paths still - * require AoT. - */ - -// GSD_TEST_MODE must be set before require('../bin/install.js') so the module -// skips the main CLI entry point and exports its internals. -const previousGsdTestMode = process.env.GSD_TEST_MODE; -process.env.GSD_TEST_MODE = '1'; - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); -const os = require('os'); -const { runNode } = require('./helpers/process-seam.cjs'); -const { throwIfFailed } = require('./helpers/git-fixture.cjs'); - -const { validateCodexConfigSchema, install } = require('../bin/install.js'); -const { cleanup } = require('./helpers.cjs'); - -if (previousGsdTestMode === undefined) { - delete process.env.GSD_TEST_MODE; -} else { - process.env.GSD_TEST_MODE = previousGsdTestMode; -} - -// Ensure hooks/dist/ is populated — mirrors the pattern used by codex-config.test.cjs. -const { before, beforeEach, afterEach } = require('node:test'); -const HOOKS_DIST = path.join(__dirname, '..', 'hooks', 'dist'); -const BUILD_HOOKS_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); -// scripts/build-hooks.js copies pre-built hook files into hooks/dist and -// syntax-checks them with vm — it does not compile/bundle anything. See -// tests/helpers/timeouts.cjs for the class-norm justification. -const { BUILD_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); -before(() => { - if (!fs.existsSync(HOOKS_DIST) || fs.readdirSync(HOOKS_DIST).length === 0) { - throwIfFailed( - runNode([BUILD_HOOKS_SCRIPT], { timeoutMs: BUILD_TIMEOUT_MS }), - `node ${BUILD_HOOKS_SCRIPT}`, - ); - } -}); - -// --------------------------------------------------------------------------- -// Validator unit tests (no install, just validateCodexConfigSchema) -// --------------------------------------------------------------------------- - -describe('#3285 — validateCodexConfigSchema: hooks.state is a regular table (not AoT)', () => { - test('bare [hooks.state] table header passes validation', () => { - const content = [ - '[hooks.state]', - '', - ].join('\n'); - const result = validateCodexConfigSchema(content); - assert.strictEqual(result.ok, true, - 'bare [hooks.state] must be allowed (regular-table namespace): ' + result.reason); - }); - - test('bare [hooks.state.] table header passes validation', () => { - // Mirrors the exact shape Codex CLI 0.130.0+ writes for per-hook trust entries. - // The key contains slashes and colons — must be quoted in TOML. - const content = [ - '[hooks.state]', - '', - "[hooks.state.'/home/user/.codex/hooks.json:pre_tool_use:0:0']", - 'enabled = true', - 'trusted_hash = "sha256:abc123"', - '', - ].join('\n'); - const result = validateCodexConfigSchema(content); - assert.strictEqual(result.ok, true, - 'bare [hooks.state.] with trust fields must be allowed: ' + result.reason); - }); - - test('hooks.state alongside [[hooks.SessionStart]] AoT both pass', () => { - // The real-world fixture: user has both Codex trust state AND GSD-managed - // event hooks in the same config.toml. - const content = [ - '[hooks.state]', - '', - "[hooks.state.'/home/user/.codex/hooks.json:pre_tool_use:0:0']", - 'enabled = true', - 'trusted_hash = "sha256:abc123"', - '', - '[[hooks.SessionStart]]', - '', - '[[hooks.SessionStart.hooks]]', - 'type = "command"', - 'command = "/usr/local/bin/gsd-check-update"', - '', - ].join('\n'); - const result = validateCodexConfigSchema(content); - assert.strictEqual(result.ok, true, - 'mixed hooks.state (regular table) + [[hooks.SessionStart]] (AoT) must pass: ' + result.reason); - }); - - test('[[hooks.SessionStart]] AoT still requires array-of-tables shape', () => { - // Regression guard: the fix must NOT relax AoT requirements for event hooks. - // [hooks.SessionStart] (single-bracket) must still fail. - const content = [ - '[hooks.SessionStart]', - 'type = "command"', - 'command = "/some/command"', - '', - ].join('\n'); - const result = validateCodexConfigSchema(content); - assert.strictEqual(result.ok, false, - '[hooks.SessionStart] bare table (not AoT) must still be rejected'); - assert.ok( - result.reason.includes('hooks.SessionStart'), - 'rejection reason must mention hooks.SessionStart, got: ' + result.reason - ); - }); - - test('hooks.state object in parsed structure does not trigger non-array rejection', () => { - // The parsed-object check loops over Object.entries(parsed.hooks) and - // asserts !Array.isArray(value) → error. hooks.state is an object, not - // an array. The fix must skip hooks.state in that loop too. - const content = [ - '[hooks.state]', - '', - "[hooks.state.'some-key']", - 'enabled = true', - 'trusted_hash = "sha256:deadbeef"', - '', - ].join('\n'); - const result = validateCodexConfigSchema(content); - assert.strictEqual(result.ok, true, - 'parsed hooks.state object must not trigger "hooks.state must be an array" rejection: ' + result.reason); - }); - - test('multiple hooks.state sub-keys all pass validation', () => { - const content = [ - '[hooks.state]', - '', - "[hooks.state.'/project/a/.codex/hooks.json:pre_tool_use:0:0']", - 'enabled = true', - 'trusted_hash = "sha256:aaa"', - '', - "[hooks.state.'/project/b/.codex/hooks.json:pre_tool_use:0:0']", - 'enabled = false', - 'trusted_hash = "sha256:bbb"', - '', - ].join('\n'); - const result = validateCodexConfigSchema(content); - assert.strictEqual(result.ok, true, - 'multiple hooks.state sub-keys must all pass: ' + result.reason); - }); - - test('[[hooks.state]] AoT form is rejected', () => { - // hooks.state must be a regular table — array-of-tables shape is invalid. - const content = [ - '[[hooks.state]]', - 'enabled = true', - '', - ].join('\n'); - const result = validateCodexConfigSchema(content); - assert.strictEqual(result.ok, false, - '[[hooks.state]] (AoT) must be rejected'); - assert.ok( - result.reason.includes('hooks.state'), - 'rejection reason must mention hooks.state, got: ' + result.reason - ); - }); - - test('[[hooks.state.foo]] AoT sub-key form is rejected', () => { - // hooks.state.* sub-keys must be regular tables — AoT sub-key shape is invalid. - const content = [ - '[[hooks.state.foo]]', - 'enabled = true', - '', - ].join('\n'); - const result = validateCodexConfigSchema(content); - assert.strictEqual(result.ok, false, - '[[hooks.state.foo]] (AoT sub-key) must be rejected'); - assert.ok( - result.reason.includes('hooks.state'), - 'rejection reason must mention hooks.state, got: ' + result.reason - ); - }); -}); - -// --------------------------------------------------------------------------- -// Full install integration test -// --------------------------------------------------------------------------- - -describe('#3285 — install succeeds when config.toml contains hooks.state entries', { concurrency: false }, () => { - let tmpDir; - let codexHome; - - function writeCodexConfig(content) { - fs.mkdirSync(codexHome, { recursive: true }); - fs.writeFileSync(path.join(codexHome, 'config.toml'), content, 'utf8'); - } - - 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'); - } finally { - process.chdir(previousCwd); - if (previousCodexHome === undefined) { - delete process.env.CODEX_HOME; - } 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; - } - } - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3285-')); - codexHome = path.join(tmpDir, 'codex-home'); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('install does not throw when config.toml contains hooks.state trust entries', () => { - // This is the exact failure scenario reported in #3285. - const preInstall = [ - '[hooks.state]', - '', - "[hooks.state.'/home/user/.codex/hooks.json:pre_tool_use:0:0']", - 'enabled = true', - 'trusted_hash = "sha256:abc123def456"', - '', - ].join('\n'); - writeCodexConfig(preInstall); - - assert.doesNotThrow( - () => runCodexInstall(), - 'install must not throw when config.toml contains hooks.state trust entries' - ); - }); - - test('hooks.state entries are preserved in post-install config.toml', () => { - const preInstall = [ - '[hooks.state]', - '', - "[hooks.state.'/home/user/.codex/hooks.json:pre_tool_use:0:0']", - 'enabled = true', - 'trusted_hash = "sha256:abc123def456"', - '', - ].join('\n'); - writeCodexConfig(preInstall); - - runCodexInstall(); - - const after = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8'); - // Verify structurally: the trust hash key must survive the install. - // Do NOT grep for the literal string — parse the TOML structure. - const { parseTomlToObject } = require('../bin/install.js'); - const parsed = parseTomlToObject(after); - assert.ok( - parsed.hooks && typeof parsed.hooks.state === 'object' && parsed.hooks.state !== null, - 'post-install config.toml must have hooks.state as an object' - ); - // Verify the actual trust entry survives — not just that hooks.state is an object. - const trustKey = "/home/user/.codex/hooks.json:pre_tool_use:0:0"; - assert.ok( - parsed.hooks.state[trustKey] != null, - `post-install must preserve the original trust entry for key: ${trustKey}` - ); - assert.strictEqual( - parsed.hooks.state[trustKey].enabled, - true, - 'preserved trust entry must have enabled = true' - ); - assert.strictEqual( - parsed.hooks.state[trustKey].trusted_hash, - 'sha256:abc123def456', - 'preserved trust entry must have the original trusted_hash' - ); - }); -}); - }); -} - - -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/bug-3346-codex-aot-toml-key.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:bug-3346-codex-aot-toml-key (consolidation epic #1969 B1 #1970)", () => { -/** - * Regression: issue #3346 — Codex install fails on Windows when the legacy - * Codex `[hooks]` config uses a `:::` location tuple - * as the table key (with the actual event name carried in an `event = "..."` - * body field). `migrateCodexHooksMapFormat` re-emitted the location tuple - * verbatim as the leaf TOML key, producing a header like - * - * [[hooks."C:\Users\helen\.codex\config.toml:session_start:0:0"]] - * - * which Codex 0.124.0+ refuses to load (the leaf key segment is supposed to - * be the event name, not a diagnostic location identifier). - * - * Expected behaviour: when the legacy `[hooks.]` body declares an - * `event = "..."` field, the migrator must use that event name as the leaf - * TOML key for the emitted `[[hooks.]]` two-level nested AoT block. - * - * Test discipline: parse the migrated TOML with the project's own - * `parseTomlToObject` and assert on the resulting object shape — never - * grep the raw string. - */ - -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); - -const { - migrateCodexHooksMapFormat, - parseTomlToObject, -} = require('../bin/install.js'); - -describe('#3346 — Codex AoT hooks migration emits event-name leaf key, not location tuple', () => { - test('legacy [hooks.""] with event="..." body migrates to [[hooks.]]', () => { - // Pre-install fixture: a legacy `[hooks.]` block whose key is - // a `:::` location identifier. The actual - // event name lives in the body as `event = "session_start"`. - const legacy = [ - '[hooks."C:\\\\Users\\\\helen\\\\.codex\\\\config.toml:session_start:0:0"]', - 'event = "session_start"', - 'command = "echo hi"', - '', - ].join('\n'); - - const migrated = migrateCodexHooksMapFormat(legacy); - const parsed = parseTomlToObject(migrated); - - // The migrated hooks object must be keyed by the event name, not by the - // location tuple. This is the core assertion of #3346. - assert.ok(parsed.hooks, 'migrated TOML must define a hooks table'); - assert.deepEqual( - Object.keys(parsed.hooks), - ['session_start'], - `migrated hooks must be keyed by event name only; got: ${JSON.stringify(Object.keys(parsed.hooks))}` - ); - - // The handler body must survive the migration and live under the two-level - // nested AoT shape (hooks.[0].hooks[0].command). - const eventEntries = parsed.hooks.session_start; - assert.ok(Array.isArray(eventEntries) && eventEntries.length >= 1, - 'hooks.session_start must be an array of tables'); - const handlers = eventEntries[0].hooks; - assert.ok(Array.isArray(handlers) && handlers.length >= 1, - 'hooks.session_start[0].hooks must be an array of handler tables'); - assert.equal(handlers[0].command, 'echo hi', - 'handler command must be preserved through migration'); - assert.equal(handlers[0].type, 'command', - 'handler type must default to "command" when no explicit type given'); - assert.equal(handlers[0].event, undefined, - 'handler body must not retain legacy `event` field after migration'); - }); - - test('legacy [hooks.""] with explicit type and event survives migration cleanly', () => { - // Same as above but with an explicit `type` field — the migrator must not - // duplicate it when re-emitting the handler. - const legacy = [ - '[hooks."/home/user/.codex/config.toml:tool_call_pre:5:0"]', - 'event = "tool_call_pre"', - 'type = "command"', - 'command = "node /path/to/hook.js"', - '', - ].join('\n'); - - const migrated = migrateCodexHooksMapFormat(legacy); - const parsed = parseTomlToObject(migrated); - - assert.deepEqual( - Object.keys(parsed.hooks), - ['tool_call_pre'], - 'leaf key must be the event name from the `event = "..."` body field' - ); - const handler = parsed.hooks.tool_call_pre[0].hooks[0]; - assert.equal(handler.command, 'node /path/to/hook.js'); - assert.equal(handler.type, 'command'); - assert.equal(handler.event, undefined, - 'handler body must not retain legacy `event` field after migration'); - }); - - test('legacy [hooks.] without location-tuple key continues to work unchanged', () => { - // Regression guard: the fix must not break the canonical legacy-map case - // ([hooks.] with handler-fields-only body, no `event` key). - const legacy = [ - '[hooks.session_start]', - 'command = "echo hi"', - '', - ].join('\n'); - - const migrated = migrateCodexHooksMapFormat(legacy); - const parsed = parseTomlToObject(migrated); - - assert.deepEqual( - Object.keys(parsed.hooks), - ['session_start'], - 'bare-event legacy shape must continue to migrate to event-named leaf key' - ); - assert.equal(parsed.hooks.session_start[0].hooks[0].command, 'echo hi'); - }); -}); - }); -} - - -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/bug-3360-codex-execute-phase-worktrees.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:bug-3360-codex-execute-phase-worktrees (consolidation epic #1969 B1 #1970)", () => { -/** - * Regression test for bug #3360. - * - * Codex does not have a direct equivalent of Claude Code's - * `Agent(... isolation="worktree")`. The execute-phase workflow must fail - * closed for Codex + workflow.use_worktrees=true instead of spawning - * workspace-write executors in the main checkout. - */ - -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); - -const ROOT = path.join(__dirname, '..'); -const EXECUTE_PHASE = path.join(ROOT, 'gsd-core', 'workflows', 'execute-phase.md'); -const { getCodexSkillAdapterHeader } = require('../bin/install.js'); - -function parseWorkflowSteps(content) { - return [...content.matchAll(/]*>([\s\S]*?)<\/step>/g)] - .map((match) => { - const body = match[2]; - return { - name: match[1], - // After #3797 architectural fix, callsites use gsd_run - readsRuntimeConfig: body.includes('RUNTIME=$(gsd_run query config-get runtime --default claude'), - // #1521 generalized the guard from Codex-specific to all non-Claude - // runtimes; #2584 Phase 3 (#2627) generalized it again — off runtime - // identity entirely and onto the negotiated `dispatch.isolation` - // capability. The step now resolves ISOLATION (delegating the block to - // the isolation-dispatch fragment) and fails closed when a host - // declares no primitive, which is what #3360 actually protects. - resolvesIsolationCapability: body.includes('Resolve ISOLATION'), - // Worktree dispatch guidance is no longer a hardcoded Claude flag — - // step 3 emits the host's DECLARED harness flag. - worktreeDispatchGuidance: body.includes('{harnessFlag}') - || body.includes('executor-isolation-dispatch.md'), - }; - }); -} - -function executePhaseWorktreeContract(content) { - const steps = parseWorkflowSteps(content); - const initializeIndex = steps.findIndex((step) => step.name === 'initialize'); - const firstWorktreeDispatchIndex = steps.findIndex((step) => step.worktreeDispatchGuidance); - assert.notEqual(initializeIndex, -1, 'workflow must have an initialize step'); - assert.notEqual(firstWorktreeDispatchIndex, -1, 'workflow must still document worktree dispatch guidance'); - - const initialize = steps[initializeIndex]; - return { - initializeReadsRuntimeConfig: initialize.readsRuntimeConfig, - initializeResolvesIsolationCapability: initialize.resolvesIsolationCapability, - guardStepPrecedesWorktreeDispatch: initializeIndex <= firstWorktreeDispatchIndex, - }; -} - -describe('#3360 — execute-phase fails closed for unsupported worktree isolation', () => { - // #2584 Phase 3 (#2627) moved this from "Codex is blocked by name" to "a host - // with no declared isolation primitive is blocked". Codex now DECLARES - // orchestrator-worktree and gets a real isolated path, so the guard can no - // longer key on its name — but #3360's actual protection (never run executors - // unisolated against the main checkout) is unchanged and asserted below. - const ISOLATION_FRAGMENT = path.join( - ROOT, 'gsd-core', 'workflows', 'execute-phase', 'steps', 'executor-isolation-dispatch.md', - ); - - test('execute-phase resolves the isolation capability before any worktree dispatch', () => { - const workflow = fs.readFileSync(EXECUTE_PHASE, 'utf8'); - const contract = executePhaseWorktreeContract(workflow); - - assert.deepEqual(contract, { - initializeReadsRuntimeConfig: true, - initializeResolvesIsolationCapability: true, - guardStepPrecedesWorktreeDispatch: true, - }); - }); - - test('a host declaring no isolation primitive still fails closed', () => { - const fragment = fs.readFileSync(ISOLATION_FRAGMENT, 'utf8'); - assert.match(fragment, /ISOLATION="?none"?/, - 'fragment must resolve the none case'); - assert.match(fragment, /FATAL[^\n]*no executor-isolation primitive/, - 'a host with dispatch.isolation=none must fail closed before dispatch (#3360)'); - assert.match(fragment, /use_worktrees=false/, - 'the fail-closed message must tell the user how to proceed'); - }); - - test('the scheduler never gates worktree dispatch on a runtime name', () => { - const workflow = fs.readFileSync(EXECUTE_PHASE, 'utf8'); - const fragment = fs.readFileSync(ISOLATION_FRAGMENT, 'utf8'); - for (const [label, src] of [['execute-phase.md', workflow], ['isolation fragment', fragment]]) { - assert.ok( - !/\[\s*"\$RUNTIME"\s*(?:!=|=)\s*"(?:codex|claude)"\s*\]\s*&&\s*\[\s*"\$USE_WORKTREES"/.test(src), - `${label}: worktree dispatch must branch on dispatch.isolation, not a runtime name (ADR-1239)`, - ); - } - }); - - test('Codex adapter documents the orchestrator-managed worktree mapping', () => { - const header = getCodexSkillAdapterHeader('gsd-execute-phase'); - assert.match(header, /isolation="worktree"/); - assert.match(header, /orchestrator-worktree/i, - 'the adapter header must no longer claim Codex has no worktree mapping — #2584 Phase 3 gave it one'); - }); -}); - }); -} - - -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/bug-3426-codex-windows-hooks.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:bug-3426-codex-windows-hooks (consolidation epic #1969 B1 #1970)", () => { -'use strict'; - -/** - * Bug #3426 — Codex on Windows: SessionStart/PostToolUse hooks fail with exit code 1 - * - * After PRs #3396/#3397 fixed bare-bash and quote-escaping issues, a new failure - * mode appeared on v1.42.3+: - * - * Failed with non-blocking status code: - * C:/Program Files/Git/bin/bash.exe: C:/Program Files/Git/bin/bash.exe: cannot execute binary file - * - * Root cause: Codex on Windows runs hook commands from a PowerShell/cmd - * execution environment (see install.js comment at buildHookCommand). The - * command string written to hooks.json was: - * - * "C:/Program Files/nodejs/node.exe" "C:/path/.codex/hooks/gsd-check-update.js" - * - * When Codex's hook runner passes this to its subprocess spawner, the quoted - * path resolves through Git Bash (MSYS), which then tries to POSIX-exec - * node.exe — a Windows PE binary — via the MSYS exec layer. The MSYS exec - * path calls execvp() on the PE binary directly, which fails with ENOEXEC, - * reported as "cannot execute binary file". The "bash.exe: bash.exe:" prefix - * appears because the error propagates through the bash.exe process that Codex - * uses as its hook-dispatch shell. - * - * Fix: on Windows, write a .cmd shim (using the same buildWindowsShimTriple - * IR pattern as gsd-sdk.cmd) and put the .cmd path as the hooks.json command. - * cmd.exe executes .cmd files natively via CreateProcess — no POSIX exec layer, - * no MSYS shebang walk. - * - * Test strategy: - * - Assert on the typed IR returned by buildCodexHookWindowsShimIR — not on - * rendered .cmd text (per CONTRIBUTING.md L558-L565 IR-first discipline). - * - Counter-tests confirm darwin/linux paths are unchanged. - * - * NOTE: Windows wall-clock verification depends on Docker matrix Windows - * runners. Local test exercises the generator IR shape only. - */ - -process.env.GSD_TEST_MODE = '1'; - -const { describe, test, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); - -const INSTALL = require('../bin/install.js'); -const HOOKS_SURFACE = require('../gsd-core/bin/lib/runtime-hooks-surface.cjs'); -const PROJECTION = require('../gsd-core/bin/lib/shell-command-projection.cjs'); -const { createTempDir, cleanup } = require('./helpers.cjs'); - -const { - uninstall, -} = INSTALL; - -const { - buildCodexHookWindowsShimIR, - ensureCodexHooksJsonSessionStart, - resolveNodeRunner, -} = HOOKS_SURFACE; - -const { projectManagedHookCommand } = PROJECTION; - -/** - * Extract hook handler objects for `eventName` from a hooks.json object. - * Handles both the legacy top-level shape { SessionStart: [...] } and the - * canonical nested shape { hooks: { SessionStart: [...] } } (bug #1348). - */ -function hookHandlersForEvent(hooksJson, eventName) { - if (!hooksJson || typeof hooksJson !== 'object') return []; - const table = - hooksJson.hooks && typeof hooksJson.hooks === 'object' && !Array.isArray(hooksJson.hooks) - ? hooksJson.hooks - : hooksJson; - if (!Array.isArray(table[eventName])) return []; - return table[eventName].flatMap((e) => Array.isArray(e && e.hooks) ? e.hooks : []); -} - -// ─── Step 1: Export surface check ──────────────────────────────────────────── - -describe('#3426 — export surface: buildCodexHookWindowsShimIR must be exported', () => { - test('buildCodexHookWindowsShimIR is a function', () => { - assert.equal(typeof buildCodexHookWindowsShimIR, 'function', - 'buildCodexHookWindowsShimIR must be exported from runtime-hooks-surface.cjs'); - }); - - test('ensureCodexHooksJsonSessionStart is a function', () => { - assert.equal(typeof ensureCodexHooksJsonSessionStart, 'function', - 'ensureCodexHooksJsonSessionStart must be exported from runtime-hooks-surface.cjs'); - }); -}); - -// ─── Step 2: Typed IR shape for Windows Codex hook shim ────────────────────── - -describe('#3426 — buildCodexHookWindowsShimIR: typed IR (not rendered text)', () => { - const FAKE_SCRIPT = 'C:/Users/me/.codex/hooks/gsd-check-update.js'; - const FAKE_RUNNER = '"C:/Program Files/nodejs/node.exe"'; - - test('returns typed IR with invocation, cmdPath, and render factory', () => { - const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, FAKE_RUNNER); - // IR shape assertion — per CONTRIBUTING.md L558 IR-first discipline - assert.ok(ir && typeof ir === 'object', 'must return an object'); - assert.ok(typeof ir.invocation === 'object', 'must have invocation record'); - assert.ok(typeof ir.cmdPath === 'string', 'must have cmdPath string'); - assert.ok(typeof ir.hookCommand === 'string', 'must have hookCommand string (written to hooks.json)'); - assert.ok(typeof ir.render === 'object', 'must have render factory'); - assert.ok(typeof ir.render.cmd === 'function', 'must have render.cmd() factory'); - }); - - test('invocation.target equals the resolved script path', () => { - const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, FAKE_RUNNER); - // invocation.target is the JS file being wrapped — same IR contract as buildWindowsShimTriple - assert.ok( - ir.invocation.target.includes('gsd-check-update.js'), - `invocation.target must reference the hook script, got: ${ir.invocation.target}`, - ); - }); - - test('invocation.interpreter is the node runner (not bash)', () => { - const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, FAKE_RUNNER); - // The shim must invoke node, never bash — bash is not a valid Codex hook runner on Windows - const interp = ir.invocation.interpreter; - assert.ok( - typeof interp === 'string' && (interp.includes('node') || interp === 'node'), - `invocation.interpreter must be a node path, not bash. Got: ${interp}`, - ); - assert.ok( - !interp.toLowerCase().includes('bash'), - `invocation.interpreter must NOT be bash — bash is the source of the #3426 failure. Got: ${interp}`, - ); - }); - - test('cmdPath ends with .cmd extension', () => { - const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, FAKE_RUNNER); - assert.ok( - ir.cmdPath.endsWith('.cmd'), - `cmdPath must end with .cmd for cmd.exe native execution, got: ${ir.cmdPath}`, - ); - }); - - test('hookCommand is the .cmd path (not a "runner script.js" string)', () => { - const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, FAKE_RUNNER); - // The hook command written to hooks.json must be the .cmd path, not "node.exe script.js" - // because cmd.exe executes .cmd natively without POSIX exec layer - assert.ok( - ir.hookCommand.includes('.cmd'), - `hookCommand must reference the .cmd shim, got: ${ir.hookCommand}`, - ); - // hookCommand must NOT contain bash — this was the failure mode - assert.ok( - !ir.hookCommand.toLowerCase().includes('bash'), - `hookCommand must NOT reference bash, got: ${ir.hookCommand}`, - ); - }); - - test('returns null when absoluteRunnerToken is null (caller skips registration)', () => { - const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, null); - assert.equal(ir, null, - 'must return null when runner is unavailable so caller can warn-and-skip'); - }); -}); - -// ─── Step 2b: Typed IR — eol / quoting / passthroughArgs ───────────────────── -// Per CONTRIBUTING.md L558-L565: assert on the typed IR, not on rendered text. -// These assertions cover the three bug-critical render semantics that -// text-matching tests would miss (silent EOL/quoting/passthrough regressions). - -describe('#3426 — buildCodexHookWindowsShimIR: typed IR eol / quoting / passthroughArgs', () => { - const FAKE_SCRIPT = 'C:/Users/me/.codex/hooks/gsd-check-update.js'; - const FAKE_RUNNER = '"C:/Program Files/nodejs/node.exe"'; - - test('eol.cmd is CRLF (\\r\\n) — canonical for cmd.exe .cmd files', () => { - const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, FAKE_RUNNER); - assert.ok(ir && typeof ir.eol === 'object', 'IR must expose an eol field'); - assert.strictEqual( - ir.eol.cmd, - '\r\n', - 'eol.cmd must be CRLF (\\r\\n) — LF-only .cmd files risk silent parse failures on some Windows versions', - ); - }); - - test('invocation.target has no shell-metachar leakage (clean absolute path)', () => { - const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, FAKE_RUNNER); - const target = ir.invocation.target; - assert.ok(typeof target === 'string' && target.length > 0, 'invocation.target must be a non-empty string'); - // The target stored in the IR is the raw unquoted path — quoting happens at - // render time. A metachar in the raw value means the IR is already corrupted. - assert.ok( - !target.includes('"') && !target.includes("'") && !target.includes('`'), - `invocation.target must be the raw path without shell quoting, got: ${target}`, - ); - assert.ok( - target.endsWith('.js'), - `invocation.target must resolve to the .js script, got: ${target}`, - ); - }); - - test('passthroughArgs is true — shim forwards all args via %*', () => { - const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, FAKE_RUNNER); - assert.strictEqual( - ir.passthroughArgs, - true, - 'passthroughArgs must be true: the .cmd shim must forward all arguments to the node script via %*', - ); - }); -}); - -// ─── Step 3: Counter-test — non-Windows platforms use node-runner command ──── - -describe('#3426 counter-test: darwin/linux Codex paths use node-runner command (not .cmd shim)', () => { - test('projectManagedHookCommand on darwin emits node-runner command, not .cmd', () => { - const runner = resolveNodeRunner() || '"/usr/local/bin/node"'; - const cmd = projectManagedHookCommand({ - absoluteRunner: runner, - scriptPath: '/Users/me/.codex/hooks/gsd-check-update.js', - runtime: 'codex', - platform: 'darwin', - }); - assert.ok(typeof cmd === 'string', 'must return a string on darwin'); - assert.ok(!cmd.endsWith('.cmd'), 'darwin command must NOT reference a .cmd shim'); - assert.ok( - cmd.includes('gsd-check-update.js'), - `darwin command must reference the .js hook directly, got: ${cmd}`, - ); - }); - - test('projectManagedHookCommand on linux emits node-runner command, not .cmd', () => { - const runner = resolveNodeRunner() || '"/usr/local/bin/node"'; - const cmd = projectManagedHookCommand({ - absoluteRunner: runner, - scriptPath: '/home/me/.codex/hooks/gsd-check-update.js', - runtime: 'codex', - platform: 'linux', - }); - assert.ok(typeof cmd === 'string', 'must return a string on linux'); - assert.ok(!cmd.endsWith('.cmd'), 'linux command must NOT reference a .cmd shim'); - assert.ok( - cmd.includes('gsd-check-update.js'), - `linux command must reference the .js hook directly, got: ${cmd}`, - ); - }); -}); - -// ─── Step 4: Integration — ensureCodexHooksJsonSessionStart on win32 writes .cmd shim ── - -describe('#3426 integration: ensureCodexHooksJsonSessionStart on win32 writes .cmd shim', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = createTempDir('gsd-3426-'); - fs.mkdirSync(path.join(tmpDir, 'hooks'), { recursive: true }); - // Stub the hook file that must exist for the hook to be registered - fs.writeFileSync( - path.join(tmpDir, 'hooks', 'gsd-check-update.js'), - '#!/usr/bin/env node\nconsole.log("ok");\n', - ); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('win32: hooks.json command references .cmd shim (not "node.exe script.js")', () => { - const fakeRunner = '"C:/Program Files/nodejs/node.exe"'; - - const result = ensureCodexHooksJsonSessionStart(tmpDir, { - absoluteRunner: fakeRunner, - platform: 'win32', - }); - - assert.ok(result.wrote || result.changed, 'must write hooks.json on win32'); - - const hooksJsonPath = path.join(tmpDir, 'hooks.json'); - assert.ok(fs.existsSync(hooksJsonPath), 'hooks.json must exist after install'); - - const hooksJson = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8')); - // #1348: hooks.json is now always written in nested { hooks: { ... } } shape - const commands = hookHandlersForEvent(hooksJson, 'SessionStart') - .map((h) => h && h.command) - .filter((c) => typeof c === 'string'); - - assert.ok(commands.length > 0, 'must have at least one SessionStart hook command'); - - const cmd = commands.find((c) => c.includes('gsd-check-update')); - assert.ok(cmd, 'must have a gsd-check-update hook command'); - - // KEY ASSERTION: on win32, the command must reference a .cmd file — not bash - assert.ok( - cmd.includes('.cmd'), - `win32 hook command must reference a .cmd shim to avoid bash.exe exec failure (#3426). Got: ${cmd}`, - ); - assert.ok( - !cmd.toLowerCase().includes('bash'), - `win32 hook command must NOT reference bash.exe — this was the #3426 failure. Got: ${cmd}`, - ); - }); - - test('win32: .cmd shim file is written to the hooks directory', () => { - const fakeRunner = '"C:/Program Files/nodejs/node.exe"'; - - ensureCodexHooksJsonSessionStart(tmpDir, { - absoluteRunner: fakeRunner, - platform: 'win32', - }); - - const cmdShimPath = path.join(tmpDir, 'hooks', 'gsd-check-update.cmd'); - assert.ok( - fs.existsSync(cmdShimPath), - `win32: .cmd shim must be written at ${cmdShimPath}`, - ); - // File must be non-empty — structure check only (IR-first discipline) - const size = fs.statSync(cmdShimPath).size; - assert.ok(size > 0, '.cmd shim must have non-zero content'); - }); - - test('non-Windows (darwin): hooks.json command is "node.exe script.js" (no .cmd shim)', () => { - const fakeRunner = '"/usr/local/bin/node"'; - - const result = ensureCodexHooksJsonSessionStart(tmpDir, { - absoluteRunner: fakeRunner, - platform: 'darwin', - }); - - assert.ok(result.wrote || result.changed, 'must write hooks.json on darwin'); - - const hooksJson = JSON.parse( - fs.readFileSync(path.join(tmpDir, 'hooks.json'), 'utf8'), - ); - // #1348: hooks.json is now always written in nested { hooks: { ... } } shape - const commands = hookHandlersForEvent(hooksJson, 'SessionStart') - .map((h) => h && h.command) - .filter((c) => typeof c === 'string'); - - const cmd = commands.find((c) => c.includes('gsd-check-update')); - assert.ok(cmd, 'must have a gsd-check-update hook command on darwin'); - - // Counter-test: darwin must NOT use a .cmd shim - assert.ok( - !cmd.endsWith('.cmd'), - `darwin hook command must NOT reference a .cmd shim, got: ${cmd}`, - ); - assert.ok( - cmd.includes('gsd-check-update.js'), - `darwin hook command must reference the .js file directly, got: ${cmd}`, - ); - - // .cmd shim must NOT be written on darwin - const cmdShimPath = path.join(tmpDir, 'hooks', 'gsd-check-update.cmd'); - assert.ok( - !fs.existsSync(cmdShimPath), - 'darwin must NOT write a .cmd shim', - ); - }); - - test('non-Windows (linux): same as darwin — no .cmd shim', () => { - const fakeRunner = '"/usr/local/bin/node"'; - - ensureCodexHooksJsonSessionStart(tmpDir, { - absoluteRunner: fakeRunner, - platform: 'linux', - }); - - const hooksJson = JSON.parse( - fs.readFileSync(path.join(tmpDir, 'hooks.json'), 'utf8'), - ); - // #1348: hooks.json is now always written in nested { hooks: { ... } } shape - const commands = hookHandlersForEvent(hooksJson, 'SessionStart') - .map((h) => h && h.command) - .filter((c) => typeof c === 'string'); - - const cmd = commands.find((c) => c.includes('gsd-check-update')); - assert.ok(cmd, 'linux must have a gsd-check-update hook command'); - assert.ok(!cmd.endsWith('.cmd'), 'linux must NOT use a .cmd shim'); - - const cmdShimPath = path.join(tmpDir, 'hooks', 'gsd-check-update.cmd'); - assert.ok(!fs.existsSync(cmdShimPath), 'linux must NOT write a .cmd shim'); - }); -}); - -// ─── Step 5: Uninstall cleanup — .cmd shim removed from disk ───────────────── - -describe('#3426 uninstall: gsd-check-update.cmd is removed from hooks dir on uninstall', () => { - let tmpDir; - - 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; - } - } - - beforeEach(() => { - tmpDir = createTempDir('gsd-3426-uninstall-'); - fs.mkdirSync(path.join(tmpDir, 'hooks'), { recursive: true }); - // Write the .js hook (required by install) and a pre-existing .cmd shim - fs.writeFileSync( - path.join(tmpDir, 'hooks', 'gsd-check-update.js'), - '#!/usr/bin/env node\nconsole.log("ok");\n', - ); - fs.writeFileSync( - path.join(tmpDir, 'hooks', 'gsd-check-update.cmd'), - '@ECHO OFF\r\n@SETLOCAL\r\n@"C:/node.exe" "C:/path/gsd-check-update.js" %*\r\n', - ); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('uninstall removes gsd-check-update.cmd from hooks directory', () => { - const cmdShimPath = path.join(tmpDir, 'hooks', 'gsd-check-update.cmd'); - assert.ok(fs.existsSync(cmdShimPath), 'pre-condition: .cmd shim exists before uninstall'); - - withCodexHome(tmpDir, () => uninstall(true, 'codex')); - - assert.ok( - !fs.existsSync(cmdShimPath), - `gsd-check-update.cmd must be removed from disk on uninstall — orphaned .cmd shim would cause stale hook references. Path: ${cmdShimPath}`, - ); - }); -}); - -// ─── Step 6: Upgrade path — existing win32 hooks.json with node-runner command ─ - -describe('#3426 upgrade: reinstall on win32 migrates existing "node script.js" to .cmd shim', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = createTempDir('gsd-3426-upgrade-'); - fs.mkdirSync(path.join(tmpDir, 'hooks'), { recursive: true }); - fs.writeFileSync( - path.join(tmpDir, 'hooks', 'gsd-check-update.js'), - '#!/usr/bin/env node\nconsole.log("ok");\n', - ); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('replaces old "node.exe script.js" command with .cmd shim on win32 reinstall', () => { - const managedHookPath = path.join(tmpDir, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/'); - // Pre-existing stale hooks.json with node-runner command (v1.42.3 shape) - const staleLegacyCommand = `"C:/Program Files/nodejs/node.exe" "${managedHookPath}"`; - fs.writeFileSync( - path.join(tmpDir, 'hooks.json'), - JSON.stringify({ - SessionStart: [{ hooks: [{ type: 'command', command: staleLegacyCommand }] }], - }, null, 2), - ); - - const fakeRunner = '"C:/Program Files/nodejs/node.exe"'; - ensureCodexHooksJsonSessionStart(tmpDir, { - absoluteRunner: fakeRunner, - platform: 'win32', - }); - - const hooksJson = JSON.parse(fs.readFileSync(path.join(tmpDir, 'hooks.json'), 'utf8')); - // #1348: hooks.json is now always written in nested { hooks: { ... } } shape - const commands = hookHandlersForEvent(hooksJson, 'SessionStart') - .map((h) => h && h.command) - .filter((c) => typeof c === 'string'); - - const gsdCmds = commands.filter((c) => c.includes('gsd-check-update')); - // Exactly one managed hook after migration — no duplicates - assert.equal(gsdCmds.length, 1, `must have exactly 1 gsd-check-update command after migration, got: ${JSON.stringify(gsdCmds)}`); - - // Must be the .cmd shim - assert.ok( - gsdCmds[0].includes('.cmd'), - `migrated command must reference .cmd shim, got: ${gsdCmds[0]}`, - ); - }); -}); - }); -} - - -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/bug-3427-3433-codex-install-shape.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:bug-3427-3433-codex-install-shape (consolidation epic #1969 B1 #1970)", () => { -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -const { describe, test, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const crypto = require('node:crypto'); -const { runNode } = require('./helpers/process-seam.cjs'); -const { throwIfFailed } = require('./helpers/git-fixture.cjs'); - -const { install, uninstall, parseTomlToObject } = require('../bin/install.js'); -const { createTempDir, cleanup, parseFrontmatter } = require('./helpers.cjs'); - -const HOOKS_DIST = path.join(__dirname, '..', 'hooks', 'dist'); -const BUILD_HOOKS_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); -// scripts/build-hooks.js copies pre-built hook files into hooks/dist and -// syntax-checks them with vm — it does not compile/bundle anything. See -// tests/helpers/timeouts.cjs for the class-norm justification. -const { BUILD_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); - -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 `/.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; - } -} - -function extractSessionStartCommandsFromHooksJson(value) { - if (!value || typeof value !== 'object' || Array.isArray(value)) return []; - const table = (value.hooks && typeof value.hooks === 'object' && !Array.isArray(value.hooks)) - ? value.hooks - : value; - const sessionStart = Array.isArray(table.SessionStart) ? table.SessionStart : []; - return sessionStart.flatMap((entry) => { - const hooks = entry && Array.isArray(entry.hooks) ? entry.hooks : []; - return hooks.map((h) => h && h.command).filter((cmd) => typeof cmd === 'string'); - }); -} - -describe('#3427 + #3433 — Codex installer avoids duplicate skills and mixed hook representation', { concurrency: false }, () => { - let tmpRoot; - let codexHome; - - beforeEach(() => { - if (!fs.existsSync(HOOKS_DIST) || fs.readdirSync(HOOKS_DIST).length === 0) { - throwIfFailed( - runNode([BUILD_HOOKS_SCRIPT], { timeoutMs: BUILD_TIMEOUT_MS }), - `node ${BUILD_HOOKS_SCRIPT}`, - ); - } - tmpRoot = createTempDir('gsd-3427-3433-'); - codexHome = path.join(tmpRoot, '.codex'); - fs.mkdirSync(codexHome, { recursive: true }); - }); - - afterEach(() => { - cleanup(tmpRoot); - }); - - test('regenerates managed gsd-* skill copies and preserves unrelated user skills (#3562 reverses prior #3427/#3433 behaviour)', () => { - // Stale legacy body — fresh install must overwrite this so Codex sees the - // current SKILL.md, not whatever was last on disk. - const legacySkillBody = '# old managed\n'; - fs.mkdirSync(path.join(codexHome, 'skills', 'gsd-help'), { recursive: true }); - fs.writeFileSync(path.join(codexHome, 'skills', 'gsd-help', 'SKILL.md'), legacySkillBody); - const legacyHash = crypto.createHash('sha256').update(legacySkillBody).digest('hex'); - fs.writeFileSync(path.join(codexHome, 'gsd-file-manifest.json'), JSON.stringify({ - version: 1, - files: { - 'skills/gsd-help/SKILL.md': legacyHash, - }, - }, null, 2)); - - fs.mkdirSync(path.join(codexHome, 'skills', 'custom-user-skill'), { recursive: true }); - fs.writeFileSync(path.join(codexHome, 'skills', 'custom-user-skill', 'SKILL.md'), '# user skill\n'); - - withCodexHome(codexHome, () => install(true, 'codex')); - - // #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 - // .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'); - - // #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', () => { - const configToml = [ - '[features]', - 'codex_hooks = true', - '', - '[[hooks.SessionStart]]', - '[[hooks.SessionStart.hooks]]', - 'type = "command"', - 'command = "node /tmp/legacy/.codex/hooks/gsd-check-update.js"', - '', - ].join('\n'); - fs.writeFileSync(path.join(codexHome, 'config.toml'), configToml); - - fs.writeFileSync(path.join(codexHome, 'hooks.json'), JSON.stringify({ - SessionStart: [ - { - hooks: [ - { type: 'command', command: 'node "/Users/example/bin/user-hook.js"' }, - ], - }, - ], - }, null, 2)); - - withCodexHome(codexHome, () => install(true, 'codex')); - - const parsedToml = parseTomlToObject(fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8')); - const tomlSessionStart = parsedToml.hooks?.SessionStart ?? []; - const tomlCommands = tomlSessionStart.flatMap((entry) => - (Array.isArray(entry?.hooks) ? entry.hooks : []).map((hook) => hook.command).filter((cmd) => typeof cmd === 'string') - ); - assert.equal(tomlCommands.some((cmd) => cmd.includes('gsd-check-update.js')), false); - - const hooksJson = JSON.parse(fs.readFileSync(path.join(codexHome, 'hooks.json'), 'utf8')); - const sessionStartCommands = extractSessionStartCommandsFromHooksJson(hooksJson); - const gsdCommands = sessionStartCommands.filter((cmd) => cmd.includes('gsd-check-update')); - - assert.equal(gsdCommands.length, 1); - assert.equal(sessionStartCommands.includes('node "/Users/example/bin/user-hook.js"'), true); - }); - - test('uninstall removes managed SessionStart hook from hooks.json but preserves user hooks', () => { - const hooksDir = path.join(codexHome, 'hooks'); - fs.mkdirSync(hooksDir, { recursive: true }); - fs.writeFileSync(path.join(hooksDir, 'gsd-check-update.js'), '// managed hook\n'); - const managedHookPath = path.join(codexHome, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/'); - - fs.writeFileSync(path.join(codexHome, 'hooks.json'), JSON.stringify({ - SessionStart: [ - { - hooks: [ - { type: 'command', command: `node "${managedHookPath}"` }, - { type: 'command', command: 'node "/Users/example/bin/user-hook.js"' }, - ], - }, - ], - }, null, 2)); - - withCodexHome(codexHome, () => uninstall(true, 'codex')); - - const hooksJson = JSON.parse(fs.readFileSync(path.join(codexHome, 'hooks.json'), 'utf8')); - const sessionStartCommands = extractSessionStartCommandsFromHooksJson(hooksJson); - // On Windows the managed hook is the .cmd shim path; on POSIX it is the .js node-runner command. - // Either way the managed hook is gone after uninstall — only the user hook remains. - const gsdCommands = sessionStartCommands.filter((cmd) => cmd.includes('gsd-check-update')); - - assert.equal(gsdCommands.length, 0); - assert.equal(sessionStartCommands.includes('node "/Users/example/bin/user-hook.js"'), true); - }); -}); - }); -} - - -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/bug-3562-codex-install-skill-surface.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:bug-3562-codex-install-skill-surface (consolidation epic #1969 B1 #1970)", () => { -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -/** - * Regression test for bug #3562 — Codex global install must create a - * discoverable $gsd-* skill surface. - * - * Codex CLI 0.130.0 (the version in the issue report) does NOT auto-discover - * commands from gsd-core/workflows/*.md or agents/*.md. It only registers - * commands from skills//SKILL.md. Prior installer logic ("Codex now - * discovers official skills from .agents/skills") was based on an assumption - * that does not match the shipping Codex CLI behavior, leaving users with - * workflows on disk and no $gsd-* entrypoints after `npx @opengsd/gsd-core - * --codex --global`. - * - * Fix: re-wire copyCommandsAsCodexSkills() back into the install dispatch path - * so the same skill-shape that Claude / Copilot / Antigravity / Cursor / - * Windsurf / Augment / Trae installs produce is also produced for Codex. - */ - -const { describe, test, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const { runNode } = require('./helpers/process-seam.cjs'); -const { throwIfFailed } = require('./helpers/git-fixture.cjs'); - -const { install } = require('../bin/install.js'); -const { createTempDir, cleanup, parseFrontmatter } = require('./helpers.cjs'); - -const HOOKS_DIST = path.join(__dirname, '..', 'hooks', 'dist'); -const BUILD_HOOKS_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); -// scripts/build-hooks.js copies pre-built hook files into hooks/dist and -// syntax-checks them with vm — it does not compile/bundle anything. See -// tests/helpers/timeouts.cjs for the class-norm justification. -const { BUILD_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); - -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 `/.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; - } -} - -describe('#3562 — Codex install produces discoverable $gsd-* skill surface', { concurrency: false }, () => { - let tmpRoot; - let codexHome; - - beforeEach(() => { - if (!fs.existsSync(HOOKS_DIST) || fs.readdirSync(HOOKS_DIST).length === 0) { - throwIfFailed( - runNode([BUILD_HOOKS_SCRIPT], { timeoutMs: BUILD_TIMEOUT_MS }), - `node ${BUILD_HOOKS_SCRIPT}`, - ); - } - tmpRoot = createTempDir('gsd-3562-'); - codexHome = path.join(tmpRoot, '.codex'); - fs.mkdirSync(codexHome, { recursive: true }); - }); - - afterEach(() => { - cleanup(tmpRoot); - }); - - test('global install creates skills/gsd-help/SKILL.md', () => { - withCodexHome(codexHome, () => install(true, 'codex')); - - // #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. ` + - 'Without this, Codex CLI 0.130.0 does not expose any $gsd-* command.', - ); - }); - - test('SKILL.md content has frontmatter expected by Codex skill discovery', () => { - withCodexHome(codexHome, () => install(true, 'codex')); - - 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'); - const frontmatter = parseFrontmatter(content); - assert.equal(frontmatter.name, 'gsd-help', 'SKILL.md frontmatter must declare name: gsd-help so $gsd-help resolves'); - }); - - test('multiple core $gsd-* skills are produced (not just gsd-help)', () => { - withCodexHome(codexHome, () => install(true, 'codex')); - - const skillsDir = codexSkillsRoot(tmpRoot); - assert.ok(fs.existsSync(skillsDir), 'skills/ directory must exist after install'); - - const gsdSkills = fs - .readdirSync(skillsDir, { withFileTypes: true }) - .filter((e) => e.isDirectory() && e.name.startsWith('gsd-')) - .map((e) => e.name); - - // Lower bound — exact count depends on the current command surface. The - // commands/gsd/ directory holds dozens of *.md files; expecting more than - // 10 generated skills is a conservative floor that catches "we generated - // nothing" or "we only generated one accidentally" regressions. - assert.ok( - gsdSkills.length >= 10, - `Expected >= 10 generated gsd-* skill directories, found ${gsdSkills.length}: ${gsdSkills.join(', ')}`, - ); - }); - - test('install preserves existing user skills (does not remove unrelated dirs)', () => { - fs.mkdirSync(path.join(codexHome, 'skills', 'custom-user-skill'), { recursive: true }); - fs.writeFileSync( - path.join(codexHome, 'skills', 'custom-user-skill', 'SKILL.md'), - '---\nname: custom-user-skill\n---\n# user skill\n', - ); - - withCodexHome(codexHome, () => install(true, 'codex')); - - const userSkill = path.join(codexHome, 'skills', 'custom-user-skill', 'SKILL.md'); - assert.ok( - fs.existsSync(userSkill), - 'Codex install must preserve existing non-gsd user skill directories', - ); - }); -}); - }); -} - - -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/bug-3566-codex-hooks-feature-canonical-key.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:bug-3566-codex-hooks-feature-canonical-key (consolidation epic #1969 B1 #1970)", () => { -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -/** - * Regression tests for bug #3566 — Codex installer must emit canonical - * [features].hooks (not the legacy [features].codex_hooks). - * - * Codex itself marks `codex_hooks` as a `legacy_key` in - * codex-rs/features/src/legacy.rs. The canonical current feature flag is - * `hooks`. The GSD installer was still writing `codex_hooks` on every fresh - * install / reinstall, leaving deprecated config behind. This file pins: - * - * 1. Fresh install writes canonical `[features].hooks = true` and never - * emits `codex_hooks` (section, root-dotted, or block-fallback forms). - * 2. Reinstall over a GSD-owned section-form legacy - * `[features].codex_hooks = true` migrates forward to - * `[features].hooks = true` (legacy line removed); user-owned legacy - * entries are preserved per #2760. - * 3. Reinstall over a GSD-owned root-dotted legacy - * `features.codex_hooks = true` migrates forward to - * `features.hooks = true`; user-owned legacy entries are preserved. - * 4. Reinstall over a user-owned `[features].hooks = true` (no GSD - * ownership marker) preserves the user line; no double-write, no - * ownership stamp. - * 5. The `hasEnabledCodexHooksFeature` recognizer treats both canonical - * `hooks` AND legacy `codex_hooks` as "enabled" so existing installs - * keep working across the migration window. - * 6. Uninstall removes either GSD-owned `hooks` or GSD-owned legacy - * `codex_hooks`; user-owned `hooks` is preserved. - * - * All assertions use parseTomlToObject — never substring-match on raw TOML - * text (per RULESET.TESTS.no-source-grep). The product surface is the - * parsed config shape, not the file's lexical layout. - */ - -const { describe, test, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const { runNode } = require('./helpers/process-seam.cjs'); -const { throwIfFailed } = require('./helpers/git-fixture.cjs'); - -const { install, uninstall, parseTomlToObject } = require('../bin/install.js'); -const { createTempDir, cleanup } = require('./helpers.cjs'); - -const HOOKS_DIST = path.join(__dirname, '..', 'hooks', 'dist'); -const BUILD_HOOKS_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); -// scripts/build-hooks.js copies pre-built hook files into hooks/dist and -// syntax-checks them with vm — it does not compile/bundle anything. See -// tests/helpers/timeouts.cjs for the class-norm justification. -const { BUILD_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); - -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 `/.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; - } -} - -function readConfig(codexHome) { - const text = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8'); - return { text, parsed: parseTomlToObject(text) }; -} - -function featuresHooks(parsed) { - return parsed?.features?.hooks; -} - -function featuresCodexHooks(parsed) { - return parsed?.features?.codex_hooks; -} - -describe('#3566 — Codex feature flag is canonical "hooks" (not legacy "codex_hooks")', { concurrency: false }, () => { - let tmpRoot; - let codexHome; - - beforeEach(() => { - if (!fs.existsSync(HOOKS_DIST) || fs.readdirSync(HOOKS_DIST).length === 0) { - throwIfFailed( - runNode([BUILD_HOOKS_SCRIPT], { timeoutMs: BUILD_TIMEOUT_MS }), - `node ${BUILD_HOOKS_SCRIPT}`, - ); - } - tmpRoot = createTempDir('gsd-3566-'); - codexHome = path.join(tmpRoot, '.codex'); - fs.mkdirSync(codexHome, { recursive: true }); - }); - - afterEach(() => { - cleanup(tmpRoot); - }); - - test('fresh install writes [features].hooks = true and never emits codex_hooks', () => { - withCodexHome(codexHome, () => install(true, 'codex')); - const { parsed } = readConfig(codexHome); - - assert.strictEqual( - featuresHooks(parsed), - true, - 'fresh install must write canonical [features].hooks = true', - ); - assert.strictEqual( - featuresCodexHooks(parsed), - undefined, - 'fresh install must NOT write legacy [features].codex_hooks', - ); - }); - - test('install over a pre-existing legacy [features].codex_hooks line preserves it (user-owned, #2760 defensive)', () => { - // A user who hand-wrote `codex_hooks = true` keeps the legacy key. - // Codex itself maps it via the runtime legacy_key alias, so this is - // forward-compatible without GSD rewriting user-authored content. - const legacy = [ - '[features]', - 'codex_hooks = true', - '', - ].join('\n'); - fs.writeFileSync(path.join(codexHome, 'config.toml'), legacy); - - withCodexHome(codexHome, () => install(true, 'codex')); - const { parsed } = readConfig(codexHome); - - assert.strictEqual( - featuresCodexHooks(parsed), - true, - 'user-owned legacy codex_hooks line must be preserved verbatim', - ); - }); - - test('install over a pre-existing legacy root-dotted features.codex_hooks line preserves it', () => { - const legacy = 'features.codex_hooks = true\n'; - fs.writeFileSync(path.join(codexHome, 'config.toml'), legacy); - - withCodexHome(codexHome, () => install(true, 'codex')); - const { parsed } = readConfig(codexHome); - - assert.strictEqual( - featuresCodexHooks(parsed), - true, - 'user-owned root-dotted legacy line must be preserved verbatim', - ); - }); - - test('reinstall preserves user-owned [features].hooks = true (no GSD ownership marker)', () => { - const userOwned = [ - '[features]', - 'hooks = true', - '', - ].join('\n'); - fs.writeFileSync(path.join(codexHome, 'config.toml'), userOwned); - - withCodexHome(codexHome, () => install(true, 'codex')); - const { parsed } = readConfig(codexHome); - - assert.strictEqual( - featuresHooks(parsed), - true, - 'user-owned hooks=true must be preserved', - ); - }); - - test('uninstall removes GSD-owned canonical hooks line but preserves user-owned hooks', () => { - // Phase 1: fresh GSD install — writes GSD-owned hooks line. - withCodexHome(codexHome, () => install(true, 'codex')); - const { parsed: afterInstall } = readConfig(codexHome); - assert.strictEqual( - featuresHooks(afterInstall), - true, - 'precondition: install wrote canonical hooks', - ); - - withCodexHome(codexHome, () => uninstall(true, 'codex')); - const configPath = path.join(codexHome, 'config.toml'); - if (!fs.existsSync(configPath)) { - // Uninstall may delete config.toml entirely when nothing user-owned - // remains — that is the strongest possible "feature flag removed" - // signal and counts as success. - return; - } - const { parsed: afterUninstall } = readConfig(codexHome); - assert.notStrictEqual( - featuresHooks(afterUninstall), - true, - 'uninstall must remove GSD-owned canonical hooks line', - ); - }); - - test('uninstall preserves user-owned hooks=true when GSD never owned it', () => { - const userOwned = [ - '[features]', - 'hooks = true', - '', - ].join('\n'); - fs.writeFileSync(path.join(codexHome, 'config.toml'), userOwned); - - withCodexHome(codexHome, () => uninstall(true, 'codex')); - const { parsed } = readConfig(codexHome); - - assert.strictEqual( - featuresHooks(parsed), - true, - 'uninstall must NOT touch a hooks line GSD never claimed ownership of (#2760 defensive principle)', - ); - }); -}); - }); -} - - -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/bug-3582-codex-skills-materialized.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:bug-3582-codex-skills-materialized (consolidation epic #1969 B1 #1970)", () => { -/** - * Regression test for bug #3582 — Codex install must materialize the skill - * surface under `~/.codex/skills//SKILL.md`. - * - * Background: GSD 1.42.2 reported the user-visible failure - * > Skipped Codex skill-copy generation (Codex discovers official skills directly) - * which left users with a "successful" install but no routable `$gsd-*` - * entrypoints in Codex CLI 0.130.0. Codex CLI does NOT auto-discover - * commands from `~/.codex/gsd-core/workflows/*.md` or `agents/*.md`; - * it only registers slash commands derived from `~/.codex/skills//SKILL.md`. - * The "Codex discovers official skills directly" assumption was wrong. - * - * The current installer (#3562 / current main) calls - * `copyCommandsAsCodexSkills()` to materialize one SKILL.md per - * commands/gsd/*.md, with Claude-flavored command frontmatter rewritten - * into Codex skill frontmatter and the `` body - * produced by `getCodexSkillAdapterHeader()`. - * - * This test locks the install contract so the 1.42.2 regression cannot - * silently come back. It asserts the full expected skill-name set - * (deepStrictEqual, not just count), the full adapter block (using - * the exported `getCodexSkillAdapterHeader` IR as the expected value, - * not raw substring search), and the success/skip log invariant. - */ -// allow-test-rule: source-text-is-the-product (see #3582) -// This assertion validates the generated adapter block that is shipped to -// users in SKILL.md; matching exact emitted text is the contract under test. - -'use strict'; - -// GSD_TEST_MODE neutralizes side-effecting branches (auto-detection, etc.). -// Must be set BEFORE requiring bin/install.js; scoped to module load only -// so downstream tests don't see it. Mirrors the bug-2760 codex harness. -const previousGsdTestMode = process.env.GSD_TEST_MODE; -process.env.GSD_TEST_MODE = '1'; - -const { test, describe, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); - -const { install, getCodexSkillAdapterHeader } = require('../bin/install.js'); -const { parseFrontmatter, createTempDir, cleanup } = require('./helpers.cjs'); - -if (previousGsdTestMode === undefined) { - delete process.env.GSD_TEST_MODE; -} else { - process.env.GSD_TEST_MODE = previousGsdTestMode; -} - -const ROOT = path.join(__dirname, '..'); -const COMMANDS_DIR = path.join(ROOT, 'commands', 'gsd'); - -// Strip ANSI color codes so log assertions don't depend on TTY detection. -function stripAnsi(s) { - // eslint-disable-next-line no-control-regex -- \x1b (ESC) is the required leading byte of ANSI SGR color sequences; matching it is the purpose of stripping ANSI codes from captured CLI/console output - return s.replace(/\x1b\[[0-9;]*m/g, ''); -} - -function assertNoBareGsdToolsInvocation(content, label) { - const patterns = [ - /(^|\n)[ \t]*gsd-tools\s/, - /\$\(\s*gsd-tools\s/, - /`\s*gsd-tools\s/, - /(?:&&|\|\||[;|])\s*gsd-tools\s/, - ]; - for (const pattern of patterns) { - assert.doesNotMatch( - content, - pattern, - `${label} must not contain a command-position bare gsd-tools invocation`, - ); - } -} - -/** - * Walk commands/gsd/**\/*.md and return the set of skill names the installer - * is contractually obligated to produce. Naming rule mirrors - * `copyCommandsAsCodexSkills` in bin/install.js: nested dirs collapse to - * `gsd--` with the .md stripped. - */ -function expectedSkillNames() { - const names = new Set(); - function recurse(dir, prefix) { - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - if (entry.isDirectory()) { - recurse(path.join(dir, entry.name), `${prefix}-${entry.name}`); - } else if (entry.name.endsWith('.md')) { - const base = entry.name.slice(0, -3); - names.add(`${prefix}-${base}`); - } - } - } - recurse(COMMANDS_DIR, 'gsd'); - return names; -} - -/** - * Run a Codex global install into a temp CODEX_HOME and capture stdout/stderr. - * Cleans up codexHome on throw so a partial-install failure never leaks - * temp directories. - */ -function runCodexInstallCaptured() { - const codexHome = createTempDir('gsd-3582-codex-'); - const logs = []; - const warnings = []; - const origLog = console.log; - const origWarn = console.warn; - console.log = (...a) => { logs.push(a.join(' ')); }; - console.warn = (...a) => { warnings.push(a.join(' ')); }; - - 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); - install(true, 'codex'); - return { codexHome, logs, warnings }; - } catch (err) { - // Always reclaim the temp dir if install throws — otherwise the - // describe-level afterEach can't see codexHome and it leaks. - try { cleanup(codexHome); } catch { /* best-effort */ } - throw err; - } finally { - process.chdir(previousCwd); - console.log = origLog; - console.warn = origWarn; - if (previousCodexHome === undefined) { - delete process.env.CODEX_HOME; - } 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 { - process.env.GSD_TEST_MODE = previousGsdTestMode; - } - } -} - -// concurrency:false — harness mutates console.* / process.env / process.cwd(). -// Matches the convention used by tests/bug-3562-codex-install-skill-surface.test.cjs. -describe('bug-3582: Codex global install materializes the skill surface', { concurrency: false }, () => { - let installRun; - - beforeEach(() => { - installRun = runCodexInstallCaptured(); - }); - - afterEach(() => { - if (installRun && installRun.codexHome) { - cleanup(installRun.codexHome); - } - }); - - test('writes the exact expected set of gsd-*/SKILL.md skills (deepEqual on name set)', () => { - const skillsDir = codexSkillsRoot(installRun.codexHome); - assert.ok( - fs.existsSync(skillsDir), - `Codex install must create ${skillsDir} (the 1.42.2 regression skipped this entirely)`, - ); - - const actualNames = fs.readdirSync(skillsDir, { withFileTypes: true }) - .filter(e => e.isDirectory() && e.name.startsWith('gsd-')) - .map(e => e.name); - - // deepStrictEqual on the sorted full set — not just count — so a - // partial install that drops a real command and substitutes a bogus - // same-count `gsd-*` directory cannot pass. - const expected = [...expectedSkillNames()].sort(); - assert.deepStrictEqual( - [...actualNames].sort(), - expected, - `installed Codex skills must exactly match commands/gsd/**/*.md (one skill per command)`, - ); - - // Every skill dir contains a non-empty SKILL.md file. Empty dirs or - // empty SKILL.md bodies would defeat Codex's slash-command - // registration as silently as the 1.42.2 "skipped" branch did. - for (const name of actualNames) { - const skillMd = path.join(skillsDir, name, 'SKILL.md'); - const stat = fs.statSync(skillMd); - assert.ok(stat.isFile(), `${skillMd} must be a regular file`); - assert.ok(stat.size > 0, `${skillMd} must not be empty`); - } - }); - - test('SKILL.md frontmatter declares hyphen-form name matching the directory', () => { - const skillsDir = codexSkillsRoot(installRun.codexHome); - const skillDirs = fs.readdirSync(skillsDir, { withFileTypes: true }) - .filter(e => e.isDirectory() && e.name.startsWith('gsd-')) - .map(e => e.name); - - for (const name of skillDirs) { - const content = fs.readFileSync( - path.join(skillsDir, name, 'SKILL.md'), - 'utf-8', - ); - // Uses the shared `parseFrontmatter` from tests/helpers.cjs per the - // CONTRIBUTING.md "tests parse, never grep" convention. - const fm = parseFrontmatter(content); - assert.strictEqual( - fm.name, - name, - `SKILL.md name field must match directory name for ${name} (got ${JSON.stringify(fm.name)})`, - ); - assert.ok( - typeof fm.description === 'string' && fm.description.length > 0, - `SKILL.md description must be a non-empty string for ${name}`, - ); - } - }); - - test('SKILL.md body contains the full block produced by the exported builder', () => { - // Structural check against the production builder's output — NOT a - // raw substring grep on the rendered file. `getCodexSkillAdapterHeader` - // is the typed IR exported by bin/install.js (#3582 PR #3609 codex - // review); the file on disk must contain its full output verbatim - // (open tag, body, closing ``). A truncated, - // empty, or missing-closing-tag adapter cannot satisfy this assertion. - const skillsDir = codexSkillsRoot(installRun.codexHome); - const skillDirs = fs.readdirSync(skillsDir, { withFileTypes: true }) - .filter(e => e.isDirectory() && e.name.startsWith('gsd-')) - .map(e => e.name); - - for (const name of skillDirs) { - const expectedAdapter = getCodexSkillAdapterHeader(name); - // Sanity: the builder itself must produce a closed block for the - // assertion below to be meaningful. - assert.ok( - expectedAdapter.startsWith(''), - `getCodexSkillAdapterHeader(${name}) must start with the opening tag`, - ); - assert.ok( - expectedAdapter.trimEnd().endsWith(''), - `getCodexSkillAdapterHeader(${name}) must end with the closing tag`, - ); - - const content = fs.readFileSync( - path.join(skillsDir, name, 'SKILL.md'), - 'utf-8', - ); - assert.ok( - content.includes(expectedAdapter), - `${name}/SKILL.md must contain the full adapter block produced by getCodexSkillAdapterHeader(${name}); Codex routes $${name} via this exact body`, - ); - } - }); - - test('representative skills named in the issue report are present', () => { - // The bug report and triage explicitly named these. Locking them as a - // representative set so a future dispatch / filter / profile change - // cannot drop just the commands the original user was trying to run. - const representative = [ - 'gsd-map-codebase', // the literal command from the bug report - 'gsd-execute-phase', - 'gsd-plan-phase', - 'gsd-new-project', - 'gsd-health', - ]; - const skillsDir = codexSkillsRoot(installRun.codexHome); - for (const name of representative) { - const skillMd = path.join(skillsDir, name, 'SKILL.md'); - assert.ok( - fs.existsSync(skillMd), - `${name}/SKILL.md must exist after Codex install (was unrouteable in 1.42.2)`, - ); - } - }); - - test('installed Codex skills do not ask agents to run bare gsd-tools commands', () => { - const skillsDir = codexSkillsRoot(installRun.codexHome); - const skillDirs = fs.readdirSync(skillsDir, { withFileTypes: true }) - .filter(e => e.isDirectory() && e.name.startsWith('gsd-')) - .map(e => e.name); - - for (const name of skillDirs) { - const content = fs.readFileSync( - path.join(skillsDir, name, 'SKILL.md'), - 'utf-8', - ); - assertNoBareGsdToolsInvocation(content, `${name}/SKILL.md`); - } - }); - - test('installer success log mentions skills/ — never claims success while skipping', () => { - // The 1.42.2 user-visible failure mode was a successful install that - // printed "Skipped Codex skill-copy generation (Codex discovers - // official skills directly)" while leaving the user with no - // entrypoints. Lock that the broken strings can NEVER coexist with a - // success indicator. Current main prints "✓ Installed N skills". - const cleanLogs = installRun.logs.map(stripAnsi); - const cleanWarnings = installRun.warnings.map(stripAnsi); - const allOutput = [...cleanLogs, ...cleanWarnings].join('\n'); - - assert.ok( - !/Skipped Codex skill-copy generation/i.test(allOutput), - `installer must never print "Skipped Codex skill-copy generation" (1.42.2 failure). Output:\n${allOutput}`, - ); - assert.ok( - !/Codex discovers official skills directly/i.test(allOutput), - `installer must never claim "Codex discovers official skills directly" (1.42.2 incorrect assumption). Output:\n${allOutput}`, - ); - - // Positive proof — at least one log line acknowledges the skills install. - const hasSkillsInstalledLog = cleanLogs.some(line => /Installed\s+\d+\s+skills\s+to\s+skills\//.test(line)); - assert.ok( - hasSkillsInstalledLog, - `installer must print a success line of the form "Installed N skills to skills/". Logs:\n${cleanLogs.join('\n')}`, - ); - }); -}); - }); -} - - -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/bug-3808-codex-adapter-text-mode-fallback.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:bug-3808-codex-adapter-text-mode-fallback (consolidation epic #1969 B1 #1970)", () => { -/** - * Regression test for bug #3808. - * - * When Codex runs in Default mode, `request_user_input` is reported as - * unavailable. The Codex skill adapter must tell the agent to activate the - * workflow's built-in TEXT_MODE mechanism (`--text` flag) rather than either: - * (a) silently picking a default value — the #3018 failure mode, or - * (b) ad-hoc plain-text fallback that bypasses the workflow's own branching. - * - * Workflows (e.g. plan-phase.md) already have TEXT_MODE logic: - * "Set TEXT_MODE=true if `--text` is present in $ARGUMENTS OR text_mode - * from init JSON is true." - * The adapter must tell the agent to USE that mechanism when - * `request_user_input` is unavailable instead of inventing its own fallback - * or silently continuing with defaults. - * - * Test design: mirrors the typed-semantic-flag pattern from bug #3018 so that - * prose rewording doesn't break tests as long as the semantics stay correct. - */ - -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const path = require('node:path'); - -const INSTALL = require(path.join(__dirname, '..', 'bin', 'install.js')); -const { getCodexSkillAdapterHeader } = INSTALL; -const { tokenizeHeadings } = require('../gsd-core/bin/lib/markdown-sectionizer.cjs'); - -/** - * Extract the "Execute mode fallback" section text from the adapter header. - * Returns null if the section is missing. Section runs from the - * "Execute mode fallback:" label up to the next heading or tag. - */ -function extractExecuteModeFallback(header) { - const label = 'Execute mode fallback:'; - const labelIdx = header.indexOf(label); - if (labelIdx === -1) return null; - const bodyStart = header.indexOf('\n', labelIdx + label.length); - if (bodyStart === -1) return null; - - // End at whichever comes first: the next "## " heading (via the canonical - // heading tokenizer, not an ad-hoc regex) or the closing adapter tag. - const headings = tokenizeHeadings(header).filter((h) => h.level === 2 && h.offset > bodyStart); - const nextHeadingOffset = headings.length > 0 ? headings[0].offset - 1 : Infinity; // -1 for the leading \n - const closeTagIdx = header.indexOf('', bodyStart); - const closeTagOffset = closeTagIdx === -1 ? Infinity : closeTagIdx - 1; // -1 for the leading \n - const bodyEnd = Math.min(nextHeadingOffset, closeTagOffset); - if (bodyEnd === Infinity) return null; - - return header.slice(bodyStart + 1, bodyEnd).trim(); -} - -/** - * Parse the Execute-mode-fallback section into a typed semantic-flag record. - * - * Flags for bug #3808 (TEXT_MODE activation): - * activatesTextMode — does the prose tell the agent to activate TEXT_MODE / use --text? - * instructsStop — does the prose tell the agent to stop/halt/wait? - * presentsPlainText — does the prose mention plain-text / numbered-list presentation? - * silentlyPicksDefaults — (anti-pattern) does the prose instruct silent-default picking? - */ -function parseExecuteModeFallbackFor3808(section) { - if (!section || typeof section !== 'string') { - return { - ok: false, - sectionLength: 0, - activatesTextMode: false, - instructsStop: false, - presentsPlainText: false, - silentlyPicksDefaults: false, - }; - } - - const lower = section.toLowerCase(); - - // (a) TEXT_MODE activation — adapter must tell the agent to use the workflow's - // built-in text mode mechanism when request_user_input is unavailable. - // Accept either: explicit "--text" flag mention OR "text_mode" / "text mode" - // paired with context showing it is being SET/ACTIVATED (not just referenced). - const mentionsTextFlag = section.includes('--text'); - const mentionsTextModeOn = /text_mode\s*=\s*true|set\s+text_mode|activate\s+text.?mode|enable\s+text.?mode|text.?mode.*active|text.?mode.*on\b/i.test(section); - const activatesTextMode = mentionsTextFlag || mentionsTextModeOn; - - // (b) STOP/WAIT directive — the agent must halt instead of proceeding silently. - const instructsStop = /\b(stop|halt|wait)\b/.test(lower); - - // (c) Plain-text fallback presentation. - const presentsPlainText = /plain.?text|numbered list/.test(lower); - - // Anti-pattern guard — the prose that caused #3018 and resurfaces in #3808. - const silentlyPicksDefaults = /pick (a |the )?(reasonable|sensible|sane) default/i.test(section); - - return { - ok: true, - sectionLength: section.length, - activatesTextMode, - instructsStop, - presentsPlainText, - silentlyPicksDefaults, - }; -} - -describe('bug #3808: codex skill adapter activates TEXT_MODE when request_user_input is unavailable', () => { - const SKILL_NAMES = ['gsd-plan-phase', 'gsd-discuss-phase', 'gsd-execute-phase', 'gsd-verify-work']; - - test('getCodexSkillAdapterHeader is exported', () => { - assert.equal(typeof getCodexSkillAdapterHeader, 'function'); - }); - - test('Execute mode fallback section exists for all key skills', () => { - for (const skillName of SKILL_NAMES) { - const header = getCodexSkillAdapterHeader(skillName); - const section = extractExecuteModeFallback(header); - assert.ok(section !== null && section.length > 0, - `${skillName}: Execute mode fallback section must exist and have content`); - } - }); - - for (const skillName of SKILL_NAMES) { - test(`${skillName}: fallback activates TEXT_MODE (--text flag or text_mode=true) when request_user_input is unavailable`, () => { - const header = getCodexSkillAdapterHeader(skillName); - const section = extractExecuteModeFallback(header); - const parsed = parseExecuteModeFallbackFor3808(section); - assert.equal(parsed.activatesTextMode, true, - `${skillName}: fallback must instruct the agent to activate TEXT_MODE (mention --text flag or text_mode=true/active) when request_user_input is unavailable (#3808). Section was:\n${section}`); - }); - - test(`${skillName}: fallback instructs STOP/WAIT (not silent continuation)`, () => { - const header = getCodexSkillAdapterHeader(skillName); - const section = extractExecuteModeFallback(header); - const parsed = parseExecuteModeFallbackFor3808(section); - assert.equal(parsed.instructsStop, true, - `${skillName}: fallback must include stop/halt/wait instruction. Section was:\n${section}`); - }); - - test(`${skillName}: fallback does NOT contain silent-default anti-pattern`, () => { - const header = getCodexSkillAdapterHeader(skillName); - const section = extractExecuteModeFallback(header); - const parsed = parseExecuteModeFallbackFor3808(section); - assert.equal(parsed.silentlyPicksDefaults, false, - `${skillName}: regression — fallback must NOT instruct the agent to pick defaults autonomously (#3018 / #3808). Section was:\n${section}`); - }); - } - - test('typed semantic-record snapshot for gsd-plan-phase — full contract', () => { - const section = extractExecuteModeFallback(getCodexSkillAdapterHeader('gsd-plan-phase')); - const parsed = parseExecuteModeFallbackFor3808(section); - assert.deepStrictEqual( - { - ok: parsed.ok, - activatesTextMode: parsed.activatesTextMode, - instructsStop: parsed.instructsStop, - presentsPlainText: parsed.presentsPlainText, - silentlyPicksDefaults: parsed.silentlyPicksDefaults, - }, - { - ok: true, - activatesTextMode: true, - instructsStop: true, - presentsPlainText: true, - silentlyPicksDefaults: false, - }, - `gsd-plan-phase: full TEXT_MODE fallback contract violated (#3808). Section was:\n${section}`, - ); - }); -}); - }); -} - - -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/bug-570-codex-leak-scanner.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:bug-570-codex-leak-scanner (consolidation epic #1969 B1 #1970)", () => { -// allow-test-rule: source-text-is-the-product (see #570) -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -/** - * Regression tests for issue #570 — three related sub-bugs in the Codex leak - * scanner and supporting infrastructure. - * - * SUB-BUG A: scanForLeakedPaths recursively scans the entire targetDir, - * including pre-existing unrelated files that contain ~/.claude references. - * Fix: scan only files listed in gsd-file-manifest.json. - * - * SUB-BUG B: convertClaudeToCodexMarkdown replaces "~/.claude/" (with trailing - * slash) but NOT bare "~/.claude" (no slash). The scanner regex - * /(?:~|\$HOME)\/\.claude\b/ matches without trailing slash. - * Fix: add bare word-boundary replacement. - * - * SUB-BUG C: writeManifest checks file.endsWith('.md') for the agents/ - * directory. Codex installs .toml agent files, so they are invisible to the - * manifest and thus to any manifest-based scan fix. - * Fix: also check .toml. - */ - -const { describe, test, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const { runNode } = require('./helpers/process-seam.cjs'); -const { throwIfFailed } = require('./helpers/git-fixture.cjs'); - -const { - install, - convertClaudeCommandToCodexSkill, -} = require('../bin/install.js'); -const { createTempDir, cleanup, captureConsole } = require('./helpers.cjs'); - -const HOOKS_DIST = path.join(__dirname, '..', 'hooks', 'dist'); -const BUILD_HOOKS_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); -// scripts/build-hooks.js copies pre-built hook files into hooks/dist and -// syntax-checks them with vm — it does not compile/bundle anything. See -// tests/helpers/timeouts.cjs for the class-norm justification. -const { BUILD_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); - -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 `/.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; - } -} - -describe('#570 — Codex leak scanner sub-bugs', { concurrency: false }, () => { - let tmpRoot; - let codexHome; - - beforeEach(() => { - if (!fs.existsSync(HOOKS_DIST) || fs.readdirSync(HOOKS_DIST).length === 0) { - throwIfFailed( - runNode([BUILD_HOOKS_SCRIPT], { timeoutMs: BUILD_TIMEOUT_MS }), - `node ${BUILD_HOOKS_SCRIPT}`, - ); - } - tmpRoot = createTempDir('gsd-570-'); - codexHome = path.join(tmpRoot, '.codex'); - fs.mkdirSync(codexHome, { recursive: true }); - }); - - afterEach(() => { - cleanup(tmpRoot); - }); - - // SUB-BUG B - test('convertClaudeToCodexMarkdown replaces bare ~/.claude (no trailing slash)', () => { - // convertClaudeToCodexMarkdown is not exported directly; exercise it via - // convertClaudeCommandToCodexSkill which calls it internally. - const input = 'configDir = ~/.claude\npath = ~/.claude/hooks/\ndir = $HOME/.claude'; - const out = convertClaudeCommandToCodexSkill(input, 'gsd-test'); - - assert.ok( - !/(?:~|\$HOME)\/\.claude\b/.test(out), - `Expected no leaked ~/.claude reference after conversion, got:\n${out}`, - ); - }); - - // SUB-BUG C - test('writeManifest includes .toml agent files for Codex', () => { - withCodexHome(codexHome, () => install(true, 'codex')); - - const agentsDir = path.join(codexHome, 'agents'); - // Not the shared listAgentFiles() helper: this reads the INSTALLED Codex - // dest dir and filters .toml (not source .md), so its semantics differ. - // Confirm that Codex actually wrote .toml agent files — if none exist the - // test is vacuous and we should fail loudly. - const tomlFiles = fs.existsSync(agentsDir) - ? fs.readdirSync(agentsDir).filter((f) => f.startsWith('gsd-') && f.endsWith('.toml')) - : []; - assert.ok( - tomlFiles.length > 0, - `Precondition: Codex install must write at least one gsd-*.toml in agents/; found none in ${agentsDir}`, - ); - - const manifestPath = path.join(codexHome, 'gsd-file-manifest.json'); - assert.ok( - fs.existsSync(manifestPath), - `gsd-file-manifest.json must exist after install; not found at ${manifestPath}`, - ); - - const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); - const manifestKeys = Object.keys(manifest.files || {}); - - const tomlManifestKeys = manifestKeys.filter( - (k) => k.startsWith('agents/gsd-') && k.endsWith('.toml'), - ); - assert.ok( - tomlManifestKeys.length > 0, - `Expected at least one 'agents/gsd-*.toml' key in manifest.files, but found none.\n` + - `agents/ toml files on disk: ${tomlFiles.join(', ')}\n` + - `All manifest keys (agents/): ${manifestKeys.filter((k) => k.startsWith('agents/')).join(', ')}`, - ); - }); - - // SUB-BUG A - test('scanForLeakedPaths does not warn for pre-existing unrelated files in ~/.codex', () => { - // Write a pre-existing file with ~/.claude references BEFORE install. - const memoriesDir = path.join(codexHome, 'memories'); - fs.mkdirSync(memoriesDir, { recursive: true }); - const preExistingFile = path.join(memoriesDir, 'raw_memories.md'); - fs.writeFileSync( - preExistingFile, - '# Old memories\nI used to work in ~/.claude and $HOME/.claude regularly.\n', - ); - - let captured; - withCodexHome(codexHome, () => { - captured = captureConsole(() => install(true, 'codex')); - }); - - const combinedOutput = captured.stderr; - - assert.ok( - !combinedOutput.includes('memories/raw_memories.md'), - `scanForLeakedPaths must not warn about pre-existing unrelated file memories/raw_memories.md.\n` + - `Actual warnings:\n${combinedOutput}`, - ); - }); -}); - }); -} - - -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/bug-704-codex-launcher-path-corruption.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:bug-704-codex-launcher-path-corruption (consolidation epic #1969 B1 #1970)", () => { -// allow-test-rule: source-text-is-the-product (see #704) -'use strict'; - -/** - * Regression test for issue #704: - * "v1.3.1 global install ships literal $gsd-core launcher paths in workflows" - * - * ROOT CAUSE: `convertSlashCommandsToCodexSkillMentions` had a regex - * /(? { - test('convertClaudeCommandToCodexSkill does not corrupt ${VAR}/gsd-core/ or $(cmd)/gsd-* paths', () => { - // Minimal fixture with the launcher snippet and command-substitution patterns - // that were being corrupted (#704). - const input = [ - '---', - 'description: Test skill', - '---', - '', - '```bash', - '_GSD_SHIM_NAME="gsd-tools.cjs"', - '_GSD_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"', - 'GSD_TOOLS="${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"', - 'if [ -f "$GSD_TOOLS" ]; then', - ' gsd_run() { node "$GSD_TOOLS" "$@"; }', - 'elif [ -f "${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then', - ' GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"', - ' gsd_run() { node "$GSD_TOOLS" "$@"; }', - 'elif [ -f "$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then', - ' GSD_TOOLS="$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"', - ' gsd_run() { node "$GSD_TOOLS" "$@"; }', - 'fi', - '# Command-substitution path form (reapply-patches pattern)', - 'candidate="$(expand_home "$KILO_CONFIG_DIR")/gsd-local-patches"', - '```', - ].join('\n'); - - const output = convertClaudeCommandToCodexSkill(input, 'gsd-test-704'); - - // Shell-context corruption patterns from issue #704: - // - `}$gsd-*` from shell variable expressions `${VAR}/gsd-*` - // - `)$gsd-*` from command-substitution paths `$(cmd)/gsd-*` - const shellCorruptionPatterns = [ - { pattern: '}' + BAD_TOKEN, description: 'shell-variable }$gsd-core' }, - { pattern: ')$gsd-local', description: 'command-substitution )$gsd-local-patches' }, - ]; - for (const { pattern, description } of shellCorruptionPatterns) { - assert.ok( - !output.includes(pattern), - `Codex skill conversion must not produce "${pattern}" (${description}). ` + - `Offending fragment: ${ - output.includes(pattern) - ? output.substring(output.indexOf(pattern) - 50, output.indexOf(pattern) + 80) - : '(not found)' - }`, - ); - } - - // The correct path forms must be preserved — the canonical launcher path - // (RUNTIME_ROOT_PATH) must survive Codex conversion intact. - assert.ok( - output.includes(RUNTIME_ROOT_PATH), - `Expected canonical launcher path "${RUNTIME_ROOT_PATH}" to appear in the converted output. ` + - `Got:\n${output.substring(0, 500)}`, - ); - assert.ok( - output.includes(')/gsd-local-patches'), - `Expected ")/gsd-local-patches" to appear in the converted output. ` + - `Got:\n${output.substring(0, 500)}`, - ); - }); - - test('convertClaudeCommandToCodexSkill preserves all shell path forms (}, ) closers)', () => { - // All these paths appear after a shell-closing character (} or )) and must - // NOT be converted to $gsd-* by the Codex slash-command converter. - const shellPaths = [ - // Shell variable expression forms (} closer) - { path: '"${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"', corruptedForm: '}$gsd-core' }, - { path: '"${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"', corruptedForm: '}$gsd-core' }, - { path: '"$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"', corruptedForm: '}$gsd-core' }, - // Command-substitution forms () closer) — reapply-patches pattern - { path: 'candidate="$(expand_home "$KILO_CONFIG_DIR")/gsd-local-patches"', corruptedForm: ')$gsd-local' }, - { path: 'candidate="$(dirname "$(expand_home "$OPENCODE_CONFIG")")/gsd-local-patches"', corruptedForm: ')$gsd-local' }, - ]; - - for (const { path: p, corruptedForm } of shellPaths) { - const input = `---\ndescription: Test\n---\n\n\`\`\`bash\n${p}\n\`\`\``; - const output = convertClaudeCommandToCodexSkill(input, 'gsd-test-704-paths'); - assert.ok( - !output.includes(corruptedForm), - `Path "${p}" was corrupted to contain "${corruptedForm}" after Codex conversion.\n` + - `Got:\n${output}`, - ); - } - }); - - test('convertClaudeCommandToCodexSkill still converts legitimate /gsd- slash mentions', () => { - // Slash-command mentions (not preceded by }) should still be converted - const input = [ - '---', - 'description: Test', - '---', - '', - 'Use /gsd-discuss-phase to start a discussion.', - 'Or use /gsd-plan-phase for planning.', - 'Also: /gsd:capture --backlog adds items.', - ].join('\n'); - - const output = convertClaudeCommandToCodexSkill(input, 'gsd-test-704-cmds'); - - assert.ok( - output.includes('$gsd-discuss-phase'), - 'Expected /gsd-discuss-phase to be converted to $gsd-discuss-phase', - ); - assert.ok( - output.includes('$gsd-plan-phase'), - 'Expected /gsd-plan-phase to be converted to $gsd-plan-phase', - ); - assert.ok( - output.includes('$gsd-capture'), - 'Expected /gsd:capture to be converted to $gsd-capture', - ); - }); - - test('actual shipped workflow files: shell-variable launcher paths contain no $gsd-core', () => { - // Walk gsd-core/workflows/ and assert that no file produces $gsd-core - // inside a shell variable expansion context after Codex conversion. - // - // NOTE: The backtick-wrapped prose-path case (`/gsd-core/workflows/update.md`) - // was a pre-existing gap with the #704 lookbehind fix and is now addressed by - // the positive-boundary regex introduced in #712. That case is covered by the - // "#712" describe block below. - // - // We probe for the specific shell-context pattern from the issue report: - // BAD: ${_GSD_RUNTIME_ROOT}$gsd-core/bin/ - // GOOD: ${_GSD_RUNTIME_ROOT}/gsd-core/bin/ - const workflowsDir = path.join(__dirname, '..', 'gsd-core', 'workflows'); - if (!fs.existsSync(workflowsDir)) { - // If the directory doesn't exist, skip gracefully (non-standard layout) - return; - } - - const files = fs.readdirSync(workflowsDir) - .filter((f) => f.endsWith('.md')) - .map((f) => path.join(workflowsDir, f)); - - assert.ok(files.length > 0, 'Expected at least one workflow .md file'); - - // Shell-context corruption patterns from issue #704: - // - `}$gsd-*`: closing brace from `${VAR}/gsd-*` shell variable expressions - // - `)$gsd-*`: closing paren from `$(cmd)/gsd-*` command substitutions - const SHELL_CORRUPTION_RE = /[})](\$gsd-[a-z])/; - - const offending = []; - for (const file of files) { - const content = fs.readFileSync(file, 'utf8'); - const skillName = `gsd-${path.basename(file, '.md')}`; - const converted = convertClaudeCommandToCodexSkill(content, skillName); - const match = converted.match(SHELL_CORRUPTION_RE); - if (match) { - const idx = converted.indexOf(match[0]); - offending.push({ - file: path.relative(workflowsDir, file), - context: converted.substring(Math.max(0, idx - 40), idx + 80), - }); - } - } - - assert.deepStrictEqual( - offending, - [], - `Found shell-context path corruption ([})]$gsd-*) in Codex-converted workflow files (#704):\n` + - offending.map((o) => ` ${o.file}: ...${o.context}...`).join('\n'), - ); - }); - - test('commands/gsd/*.md: shell-variable launcher paths contain no $gsd-core', () => { - // Walk commands/gsd/ and assert that no command file produces the shell-context - // }$gsd-core corruption — since commands also go through - // convertClaudeCommandToCodexSkill when installed globally for Codex. - const commandsDir = path.join(__dirname, '..', 'commands', 'gsd'); - if (!fs.existsSync(commandsDir)) return; - - const files = fs.readdirSync(commandsDir) - .filter((f) => f.endsWith('.md')) - .map((f) => path.join(commandsDir, f)); - - assert.ok(files.length > 0, 'Expected at least one command .md file'); - - const SHELL_CORRUPTION_RE = /[})](\$gsd-[a-z])/; - - const offending = []; - for (const file of files) { - const content = fs.readFileSync(file, 'utf8'); - const skillName = `gsd-${path.basename(file, '.md')}`; - const converted = convertClaudeCommandToCodexSkill(content, skillName); - const match = converted.match(SHELL_CORRUPTION_RE); - if (match) { - const idx = converted.indexOf(match[0]); - offending.push({ - file: path.relative(commandsDir, file), - context: converted.substring(Math.max(0, idx - 40), idx + 80), - }); - } - } - - assert.deepStrictEqual( - offending, - [], - `Found shell-context path corruption ([})]$gsd-*) in Codex-converted command files (#704):\n` + - offending.map((o) => ` ${o.file}: ...${o.context}...`).join('\n'), - ); - }); -}); - -describe('#712: positive-boundary slash-command conversion', () => { - // Tests call convertSlashCommandsToCodexSkillMentions directly so the regex - // is exercised in isolation — no frontmatter wrapping, no ADAPTER_CLOSE - // stripping, no .claude→.codex rewrite masking the result. - - // ── MUST-NOT-CONVERT (negative) cases ───────────────────────────────────── - // These inputs must be returned UNCHANGED — no $gsd-* substitution. - - test('backtick-wrapped path: `/gsd-core/workflows/update.md` is NOT converted (THE new fix)', () => { - const input = 'See `/gsd-core/workflows/update.md` for details.'; - const result = convertSlashCommandsToCodexSkillMentions(input); - assert.strictEqual( - result, - input, - `Expected backtick-wrapped path to be unchanged. Got: ${result}`, - ); - }); - - test('backtick-wrapped path deeper: `/gsd-pi/bin/foo.cjs` is NOT converted', () => { - const input = 'Run `/gsd-pi/bin/foo.cjs` directly.'; - const result = convertSlashCommandsToCodexSkillMentions(input); - assert.strictEqual( - result, - input, - `Expected deep backtick-wrapped path to be unchanged. Got: ${result}`, - ); - }); - - test('shell var expansion: ${_GSD_RUNTIME_ROOT}/gsd-core/bin/x is NOT converted (regression guard)', () => { - const input = 'PATH="${_GSD_RUNTIME_ROOT}/gsd-core/bin/x"'; - const result = convertSlashCommandsToCodexSkillMentions(input); - assert.ok( - !result.includes('$gsd-core'), - `Expected no $gsd-core substitution in shell var path. Got: ${result}`, - ); - assert.ok( - result.includes('/gsd-core/bin/x'), - `Expected original path to be preserved. Got: ${result}`, - ); - }); - - test('command substitution: $(expand_home ~/.claude)/gsd-local-patches is NOT converted (regression guard)', () => { - const input = 'candidate="$(expand_home ~/.claude)/gsd-local-patches"'; - const result = convertSlashCommandsToCodexSkillMentions(input); - assert.ok( - !result.includes(')$gsd-local'), - `Expected no )$gsd-local substitution. Got: ${result}`, - ); - assert.ok( - result.includes(')/gsd-local-patches'), - `Expected original path to be preserved. Got: ${result}`, - ); - }); - - test('plain path segment: bin/gsd-tools.cjs is NOT converted', () => { - const input = 'node bin/gsd-tools.cjs --help'; - const result = convertSlashCommandsToCodexSkillMentions(input); - assert.strictEqual( - result, - input, - `Expected plain path segment to be unchanged. Got: ${result}`, - ); - }); - - test('plain path segment: .claude/gsd-core/agents — /gsd-core portion is NOT slash-command converted', () => { - // Tests the regex in isolation: the .claude→.codex path rewrite that happens - // inside convertClaudeToCodexMarkdown does NOT run here. We assert directly - // that the slash-command regex leaves /gsd-core after the slash intact — - // i.e. the `e` in `/gsd-core` is NOT treated as a command boundary. - const input = 'Look in .claude/gsd-core/agents for the agent files.'; - const result = convertSlashCommandsToCodexSkillMentions(input); - assert.ok( - !result.includes('$gsd-core'), - `Expected no $gsd-core substitution in .claude/gsd-core path. Got: ${result}`, - ); - assert.ok( - result.includes('/gsd-core/agents'), - `Expected /gsd-core/agents to remain as a path segment. Got: ${result}`, - ); - }); - - // ── MUST-CONVERT (positive) cases ───────────────────────────────────────── - // These inputs contain legitimate /gsd- mentions that MUST be converted. - - test('space-preceded prose: Use /gsd-discuss-phase to start. → $gsd-discuss-phase', () => { - const input = 'Use /gsd-discuss-phase to start.'; - const result = convertSlashCommandsToCodexSkillMentions(input); - assert.ok( - result.includes('$gsd-discuss-phase'), - `Expected /gsd-discuss-phase to be converted. Got: ${result}`, - ); - assert.ok( - !result.includes('/gsd-discuss-phase'), - `Expected original /gsd-discuss-phase to be replaced. Got: ${result}`, - ); - }); - - test('backtick-WRAPPED MENTION (single segment): Run `/gsd-execute-phase` now → `$gsd-execute-phase`', () => { - // A backtick-wrapped COMMAND (single segment, no path continuation) MUST - // still be converted — this guards against a naive whitespace-only fix. - const input = 'Run `/gsd-execute-phase` now.'; - const result = convertSlashCommandsToCodexSkillMentions(input); - assert.ok( - result.includes('`$gsd-execute-phase`'), - `Expected backtick-wrapped command to be converted to \`$gsd-execute-phase\`. Got: ${result}`, - ); - assert.ok( - !result.includes('`/gsd-execute-phase`'), - `Expected original \`/gsd-execute-phase\` to be replaced. Got: ${result}`, - ); - }); - - test('parenthetical/backtick list like CONTEXT.md:59: (`/gsd-plan-phase`, `/gsd-progress`) → converted', () => { - const input = 'Available commands: (`/gsd-plan-phase`, `/gsd-progress`) — pick one.'; - const result = convertSlashCommandsToCodexSkillMentions(input); - assert.ok( - result.includes('`$gsd-plan-phase`'), - `Expected /gsd-plan-phase to be converted. Got: ${result}`, - ); - assert.ok( - result.includes('`$gsd-progress`'), - `Expected /gsd-progress to be converted. Got: ${result}`, - ); - }); - - test('start-of-string: /gsd-manager runs → $gsd-manager runs (exercises the ^ branch of lookbehind)', () => { - // This case is IMPOSSIBLE to test through the frontmatter-wrapping pipeline - // (the body always has preceding chars). Direct call exercises the ^ branch. - const input = '/gsd-manager runs the pipeline.'; - const result = convertSlashCommandsToCodexSkillMentions(input); - assert.ok( - result.includes('$gsd-manager'), - `Expected /gsd-manager to be converted. Got: ${result}`, - ); - assert.ok( - !result.includes('/gsd-manager'), - `Expected original /gsd-manager to be replaced. Got: ${result}`, - ); - }); - - test('double-quote wrapped: "/gsd-resume" → "$gsd-resume"', () => { - const input = 'Call "/gsd-resume" to continue.'; - const result = convertSlashCommandsToCodexSkillMentions(input); - assert.ok( - result.includes('"$gsd-resume"'), - `Expected "/gsd-resume" to be converted to "$gsd-resume". Got: ${result}`, - ); - assert.ok( - !result.includes('"/gsd-resume"'), - `Expected original "/gsd-resume" to be replaced. Got: ${result}`, - ); - }); - - // ── End-to-end: headline #712 bug through the real install pipeline ──────── - - test('end-to-end: backtick-wrapped path `/gsd-core/workflows/update.md` survives full Codex install pipeline', () => { - // Uses convertClaudeCommandToCodexSkill (same pattern as #704 tests above) - // to prove the real install path does not corrupt prose references to repo paths. - const input = [ - '---', - 'description: Test', - '---', - '', - 'See `/gsd-core/workflows/update.md` for the update workflow.', - ].join('\n'); - - const output = convertClaudeCommandToCodexSkill(input, 'gsd-test-712-e2e'); - - assert.ok( - !output.includes('$gsd-core'), - `Expected no $gsd-core in converted output. Got:\n${output}`, - ); - assert.ok( - output.includes('/gsd-core/workflows/update.md'), - `Expected backtick-wrapped path to survive conversion. Got:\n${output}`, - ); - }); -}); - }); -} - - -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/bug-851-codex-quick-adapter-agent-type-fallback.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:bug-851-codex-quick-adapter-agent-type-fallback (consolidation epic #1969 B1 #1970)", () => { -// allow-test-rule: source-text-is-the-product (see #851) -// Tests assert on text in bin/install.js (Codex adapter header prose) — -// the adapter text IS the product loaded by Codex agents at runtime. - -'use strict'; - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); - -const INSTALL_JS = path.join(__dirname, '..', 'bin', 'install.js'); -const src = fs.readFileSync(INSTALL_JS, 'utf8'); - -// Helper: extract Section C from the raw source text. -// Anchors on the heading and ends at . -function getSectionC() { - // allow-test-rule: source-text-is-the-product (see #851) - const headingIdx = src.indexOf('## C. Task() → spawn_agent Mapping'); // allow-test-rule: source-text-is-the-product (see #851) - assert.ok(headingIdx >= 0, 'Section C heading must exist in bin/install.js'); - const closeTag = src.indexOf('', headingIdx); // allow-test-rule: source-text-is-the-product (see #851) - assert.ok(closeTag >= 0, 'Section C must be followed by '); - return src.slice(headingIdx, closeTag); -} - -describe('bug #851: Codex adapter documents multi_agent_v1 schema limitation and fallback', () => { - - // (a) Schema-detection step: the adapter must require the agent to inspect - // spawn_agent's parameter schema BEFORE deciding how to dispatch. - test('(a) schema-detection: adapter requires inspecting spawn_agent schema before dispatching', () => { - const sectionC = getSectionC(); - - // Must name BOTH schema variants so the agent knows what to look for - assert.ok( - sectionC.includes('multi_agent_v1'), - 'Section C must name the multi_agent_v1 schema to identify the limited form', - ); - assert.ok( - sectionC.includes('multi_agent_v2') || sectionC.includes('agent_type-capable'), - 'Section C must name the typed schema (multi_agent_v2 or agent_type-capable) as the capable form', - ); - - // Must instruct schema inspection before spawning - assert.ok( - sectionC.includes('tool_search') || sectionC.includes('inspect') || sectionC.includes('schema'), - 'Section C must instruct the agent to inspect the spawn_agent schema (via tool_search or similar)', - ); - - // All three requirements together (AND): - assert.ok( - sectionC.includes('multi_agent_v1') && - (sectionC.includes('multi_agent_v2') || sectionC.includes('agent_type-capable')) && - (sectionC.includes('tool_search') || sectionC.includes('inspect') || sectionC.includes('schema')), - 'Section C must require schema-detection: name both schema variants AND instruct inspection before spawning', - ); - }); - - // (b) Active-config-root resolution: the TOML path must describe how to - // resolve the config root (honoring $CODEX_HOME / --config-dir / --local), - // not imply a single fixed path. - test('(b) active-config-root: fallback TOML path resolves the active Codex config root', () => { - const sectionC = getSectionC(); - - // Must mention the agents/.toml relative path - assert.ok( - sectionC.includes('agents/.toml'), - 'Section C must reference agents/.toml for the TOML extraction step', - ); - - // Must describe dynamic config-root resolution (at least two of the three - // override mechanisms, plus the word "config" to anchor context) - const mentionsCodexHome = sectionC.includes('$CODEX_HOME') || sectionC.includes('CODEX_HOME'); - const mentionsConfigDir = sectionC.includes('--config-dir') || sectionC.includes('config-dir'); - const mentionsLocal = sectionC.includes('--local') || sectionC.includes('.codex') || sectionC.includes('local'); - const mentionsConfigRoot = sectionC.includes('config root') || sectionC.includes('config.toml') || sectionC.includes('config directory'); - - assert.ok( - mentionsCodexHome, - 'Section C fallback must mention $CODEX_HOME for config-root resolution', - ); - assert.ok( - mentionsConfigDir, - 'Section C fallback must mention --config-dir for config-root resolution', - ); - assert.ok( - mentionsLocal, - 'Section C fallback must mention --local / .codex for config-root resolution', - ); - assert.ok( - mentionsConfigRoot, - 'Section C fallback must describe the concept of an active config root (config.toml or config root/directory)', - ); - - // AND: all four required elements together - assert.ok( - mentionsCodexHome && mentionsConfigDir && mentionsLocal && mentionsConfigRoot, - 'Section C fallback must describe active-config-root resolution: $CODEX_HOME + --config-dir + --local + config-root concept (AND logic)', - ); - - // Must NOT contain the literal ~/.codex/ (would be rewritten by _applyRuntimeRewrites - // and cause bug-3582 to diverge) - assert.ok( - !sectionC.includes('~/.codex/'), - 'Section C must NOT contain the literal ~/.codex/ substring (breaks bug-3582 materialization test)', - ); - }); - - // (c) "NOT equivalent" label: the workaround must be explicitly labeled as - // not equivalent to typed gsd-planner/gsd-executor execution. - test('(c) not-equivalent label: generic-agent workaround is labeled as NOT equivalent to typed dispatch', () => { - const sectionC = getSectionC(); - - // Must name at least one typed agent - const namesTypedAgent = - sectionC.includes('gsd-planner') || - sectionC.includes('gsd-executor') || - sectionC.includes('typed GSD agent') || - sectionC.includes('typed gsd-'); - - // Must contain explicit "not equivalent" / "NOT equivalent" / negation language - const hasNotEquivalent = - sectionC.toLowerCase().includes('not equivalent') || - sectionC.includes('NOT equivalent') || - sectionC.includes('is NOT possible'); - - // Must name the workaround as a workaround, not a first-class path - const hasWorkaroundLabel = - sectionC.includes('workaround') || - sectionC.includes('fallback'); - - assert.ok( - namesTypedAgent, - 'Section C must name at least one typed GSD agent (gsd-planner, gsd-executor, or "typed GSD agent")', - ); - assert.ok( - hasNotEquivalent, - 'Section C must contain explicit "not equivalent" / "NOT equivalent" language for the generic-agent path', - ); - assert.ok( - hasWorkaroundLabel, - 'Section C must label the generic-agent path as a workaround or fallback', - ); - - // AND: all three together - assert.ok( - namesTypedAgent && hasNotEquivalent && hasWorkaroundLabel, - 'Section C must AND: name a typed agent + label it NOT equivalent + call the generic path a workaround/fallback', - ); - }); - - // (d) Fail-closed rule: when typed dispatch is mandatory, the adapter must - // instruct the agent to fail closed and report the limitation, not silently degrade. - test('(d) fail-closed: adapter requires failing closed when typed dispatch is mandatory', () => { - const sectionC = getSectionC(); - - const hasFailClosed = - sectionC.includes('fail closed') || - sectionC.includes('fail-closed') || - sectionC.includes('fail_closed'); - - const hasReportLimitation = - sectionC.includes('schema limitation') || - sectionC.includes('report') || - sectionC.includes('not silently') || - sectionC.includes('silently degrading') || - sectionC.includes('silently'); - - const hasMandatoryContext = - sectionC.includes('mandatory') || - sectionC.includes('required') || - sectionC.includes('worktree isolation') || - sectionC.includes('isolation'); - - assert.ok( - hasFailClosed, - 'Section C must instruct fail-closed behavior (the phrase "fail closed" or equivalent)', - ); - assert.ok( - hasReportLimitation, - 'Section C must instruct reporting the schema limitation rather than silently degrading', - ); - assert.ok( - hasMandatoryContext, - 'Section C must identify a context where typed dispatch is mandatory (e.g. worktree isolation)', - ); - - // AND: all three together - assert.ok( - hasFailClosed && hasReportLimitation && hasMandatoryContext, - 'Section C must AND: instruct fail-closed + report limitation + identify mandatory-typed-dispatch contexts', - ); - }); - - // Regression guard: typed mapping for capable schema must still be present. - test('adapter still documents typed agent_type spawn for sessions that support it', () => { - const sectionC = getSectionC(); - - assert.ok( - sectionC.includes('agent_type-capable') || sectionC.includes('multi_agent_v2'), - 'Section C must still document the typed schema (agent_type-capable / multi_agent_v2)', - ); - assert.ok( - sectionC.includes('spawn_agent(agent_type=') || sectionC.includes('agent_type="X"'), - 'Section C must still show a typed spawn_agent(agent_type=...) example for capable sessions', - ); - }); - - // Regression guard: deferred tool discovery must remain (bug-279 contract). - test('adapter deferred tool discovery instruction is preserved', () => { - // The pre-existing bug-279 contract must remain intact - // allow-test-rule: source-text-is-the-product (see #851) - assert.ok( - src.includes('deferred') && src.includes('tool_search') && src.includes('spawn_agent'), // allow-test-rule: source-text-is-the-product (see #851) - 'Adapter must still instruct deferred tool discovery via tool_search before deciding to run inline', - ); - }); -}); - }); -} - - -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/enh-772-codex-hook-events.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:enh-772-codex-hook-events (consolidation epic #1969 B1 #1970)", () => { -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -/** - * Enhancement #772: Adopt new stable Codex hook events + commandWindows for - * Windows parity. - * - * Codex CLI (rust-v0.137.0) stabilised the full hook-event set. This suite - * asserts that a Codex install: - * - * (a) Registers the 3 new high-value hook events in hooks.json: - * - SubagentStart — inject context / GSD_AGENT_NAME awareness at subagent open - * - Stop — post-session context headroom tracking - * - PostToolUse — mirror the Claude Code PostToolUse context monitor - * - * (b) Emits `commandWindows` in the SessionStart hooks.json entry so that - * Windows users get the .cmd shim path and non-Windows users get the POSIX - * node runner command. Both fields are present in the same entry; Codex picks - * the right one per its HookHandlerConfig schema - * (codex-rs/config/src/hook_config.rs: commandWindows / command_windows alias). - * - * Note: UserPromptSubmit is NOT wired (same rationale as Qwen #788 — the - * gsd-prompt-guard handler exits unless tool_name is Write|Edit, so it would be - * a silent no-op for the UserPromptSubmit payload shape). - * - * Test strategy: - * - Test new event registration via ensureCodexHooksJsonEvent() directly - * (mirrors the #3426 pattern of testing ensureCodexHooksJsonSessionStart - * directly with a stub hook file — avoids full install() migration dance). - * - Test commandWindows via ensureCodexHooksJsonSessionStart() directly. - * - IR-first discipline: assert on the structured result, not rendered text. - * - * Verified hook event schema: - * https://github.com/openai/codex/blob/main/codex-rs/protocol/src/protocol.rs - * https://github.com/openai/codex/blob/main/codex/codex-rs/config/src/hook_config.rs - */ - -const { test, describe, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); - -const { - ensureCodexHooksJsonSessionStart, - ensureCodexHooksJsonEvent, - removeCodexHooksJsonEvent, - reconcileCodexHooksJsonEvent, -} = require('../gsd-core/bin/lib/runtime-hooks-surface.cjs'); -const { createTempDir, cleanup } = require('./helpers.cjs'); - -// ─── Helpers ───────────────────────────────────────────────────────────────── - -/** - * Extract all hook handler entries (full objects with type/command/etc.) for - * `eventName` from a hooks.json object (flat or nested-hooks shape). - */ -function hooksJsonHandlersForEvent(hooksJson, eventName) { - if (!hooksJson || typeof hooksJson !== 'object') return []; - const table = - hooksJson.hooks && typeof hooksJson.hooks === 'object' && !Array.isArray(hooksJson.hooks) - ? hooksJson.hooks - : hooksJson; - if (!Array.isArray(table[eventName])) return []; - return table[eventName].flatMap(entry => - Array.isArray(entry && entry.hooks) ? entry.hooks : [] - ); -} - -function readHooksJson(targetDir) { - const p = path.join(targetDir, 'hooks.json'); - if (!fs.existsSync(p)) return null; - return JSON.parse(fs.readFileSync(p, 'utf8')); -} - -function stubHookFile(targetDir, hookName) { - const hooksDest = path.join(targetDir, 'hooks'); - fs.mkdirSync(hooksDest, { recursive: true }); - const dest = path.join(hooksDest, hookName); - if (!fs.existsSync(dest)) { - fs.writeFileSync(dest, '#!/usr/bin/env node\n// stub\n'); - try { fs.chmodSync(dest, 0o755); } catch { /* Windows */ } - } -} - -// ─── Suite 1: ensureCodexHooksJsonEvent export surface ─────────────────────── - -describe('enh-772: export surface — new functions are exported', () => { - test('ensureCodexHooksJsonEvent is a function', () => { - assert.strictEqual(typeof ensureCodexHooksJsonEvent, 'function', - 'ensureCodexHooksJsonEvent must be exported from runtime-hooks-surface.cjs'); - }); - - test('removeCodexHooksJsonEvent is a function', () => { - assert.strictEqual(typeof removeCodexHooksJsonEvent, 'function', - 'removeCodexHooksJsonEvent must be exported from runtime-hooks-surface.cjs'); - }); - - test('reconcileCodexHooksJsonEvent is a function', () => { - assert.strictEqual(typeof reconcileCodexHooksJsonEvent, 'function', - 'reconcileCodexHooksJsonEvent must be exported from runtime-hooks-surface.cjs'); - }); -}); - -// ─── Suite 2: ensureCodexHooksJsonEvent registers new events ───────────────── - -describe('enh-772: ensureCodexHooksJsonEvent registers SubagentStart, Stop, PostToolUse', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = createTempDir('gsd-772-events-'); - stubHookFile(tmpDir, 'gsd-context-monitor.js'); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - for (const eventName of ['SubagentStart', 'Stop', 'PostToolUse']) { - test(`${eventName}: ensureCodexHooksJsonEvent writes hooks.json`, () => { - const fakeRunner = '"/usr/local/bin/node"'; - const result = ensureCodexHooksJsonEvent(tmpDir, eventName, { - absoluteRunner: fakeRunner, - platform: 'linux', - }); - assert.ok(result && result.path, `result must have path for ${eventName}`); - assert.ok(result.wrote || result.changed, - `ensureCodexHooksJsonEvent must write or change hooks.json for ${eventName}`); - assert.ok(fs.existsSync(path.join(tmpDir, 'hooks.json')), - `hooks.json must exist after registering ${eventName}`); - }); - - test(`${eventName}: hooks.json contains the event entry`, () => { - const fakeRunner = '"/usr/local/bin/node"'; - ensureCodexHooksJsonEvent(tmpDir, eventName, { - absoluteRunner: fakeRunner, - platform: 'linux', - }); - const hooksJson = readHooksJson(tmpDir); - const handlers = hooksJsonHandlersForEvent(hooksJson, eventName); - assert.ok(handlers.length > 0, - `Expected ${eventName} entry in hooks.json; got: ${JSON.stringify(hooksJson)}`); - }); - - test(`${eventName}: hook entry uses gsd-context-monitor`, () => { - const fakeRunner = '"/usr/local/bin/node"'; - ensureCodexHooksJsonEvent(tmpDir, eventName, { - absoluteRunner: fakeRunner, - platform: 'linux', - }); - const hooksJson = readHooksJson(tmpDir); - const handlers = hooksJsonHandlersForEvent(hooksJson, eventName); - assert.ok( - handlers.some(h => h.command && h.command.includes('gsd-context-monitor')), - `${eventName} hook must use gsd-context-monitor; got: ${JSON.stringify(handlers)}` - ); - }); - - test(`${eventName}: hook entry has type: 'command'`, () => { - const fakeRunner = '"/usr/local/bin/node"'; - ensureCodexHooksJsonEvent(tmpDir, eventName, { - absoluteRunner: fakeRunner, - platform: 'linux', - }); - const hooksJson = readHooksJson(tmpDir); - const handlers = hooksJsonHandlersForEvent(hooksJson, eventName); - const entry = handlers.find(h => h.command && h.command.includes('gsd-context-monitor')); - assert.strictEqual(entry && entry.type, 'command', - `${eventName} hook entry must have type 'command'`); - }); - - test(`${eventName}: hook entry has timeout: 10`, () => { - const fakeRunner = '"/usr/local/bin/node"'; - ensureCodexHooksJsonEvent(tmpDir, eventName, { - absoluteRunner: fakeRunner, - platform: 'linux', - }); - const hooksJson = readHooksJson(tmpDir); - const handlers = hooksJsonHandlersForEvent(hooksJson, eventName); - const entry = handlers.find(h => h.command && h.command.includes('gsd-context-monitor')); - assert.strictEqual(entry && entry.timeout, 10, - `${eventName} hook entry must have timeout 10`); - }); - } - - test('null absoluteRunner returns unchanged result without writing', () => { - const result = ensureCodexHooksJsonEvent(tmpDir, 'SubagentStart', { - absoluteRunner: null, - platform: 'linux', - }); - assert.strictEqual(result.changed, false, - 'null runner must return changed: false'); - assert.ok(!fs.existsSync(path.join(tmpDir, 'hooks.json')), - 'hooks.json must NOT be written when runner is null'); - }); -}); - -// ─── Suite 3: commandWindows parity in SessionStart ────────────────────────── - -describe('enh-772: commandWindows parity — ensureCodexHooksJsonSessionStart emits commandWindows', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = createTempDir('gsd-772-cmdwin-'); - stubHookFile(tmpDir, 'gsd-check-update.js'); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - // commandWindows is ONLY emitted on win32 platform (where the .cmd shim is also - // written). On POSIX platforms, commandWindows is omitted to avoid pointing Windows - // Codex at a non-existent .cmd file (the shim is only present after a native Windows - // install that runs buildCodexHookWindowsShimIR and atomicWriteFileSync). - - test('POSIX platform: commandWindows is NOT emitted (shim not written on POSIX)', () => { - const fakeRunner = '"/usr/local/bin/node"'; - const result = ensureCodexHooksJsonSessionStart(tmpDir, { - absoluteRunner: fakeRunner, - platform: 'linux', - }); - assert.ok(result && result.wrote, 'must write hooks.json on linux'); - - const hooksJson = readHooksJson(tmpDir); - const handlers = hooksJsonHandlersForEvent(hooksJson, 'SessionStart'); - assert.ok(handlers.length > 0, `Expected SessionStart handlers; got: ${JSON.stringify(hooksJson)}`); - - const entry = handlers[0]; - assert.ok( - entry.commandWindows === undefined, - `commandWindows must NOT be emitted on POSIX (shim not written); got: ${JSON.stringify(entry)}` - ); - }); - - test('POSIX platform: command references gsd-check-update.js (not .cmd)', () => { - const fakeRunner = '"/usr/local/bin/node"'; - ensureCodexHooksJsonSessionStart(tmpDir, { - absoluteRunner: fakeRunner, - platform: 'linux', - }); - const hooksJson = readHooksJson(tmpDir); - const handlers = hooksJsonHandlersForEvent(hooksJson, 'SessionStart'); - const entry = handlers[0]; - assert.ok( - entry.command && entry.command.includes('gsd-check-update'), - `POSIX command must reference gsd-check-update; got: ${entry.command}` - ); - assert.ok( - !entry.command.endsWith('.cmd') && !entry.command.endsWith('.cmd"'), - `POSIX command must not end with .cmd; got: ${entry.command}` - ); - }); - - test('null absoluteRunner: no commandWindows emitted, no write', () => { - const result = ensureCodexHooksJsonSessionStart(tmpDir, { - absoluteRunner: null, - platform: 'linux', - }); - assert.strictEqual(result.changed, false, 'null runner must return changed: false'); - const hooksJson = readHooksJson(tmpDir); - if (hooksJson) { - const handlers = hooksJsonHandlersForEvent(hooksJson, 'SessionStart'); - for (const h of handlers) { - assert.ok(!h.commandWindows, - `commandWindows must not be present when runner is null; got: ${JSON.stringify(h)}`); - } - } - }); - - test('Windows platform: SessionStart hook is written with commandWindows pointing to .cmd shim', () => { - // On win32, both `command` and `commandWindows` use the .cmd shim path - // (because managedCommand = shimIR.hookCommand = .cmd path, and - // commandWindows = same .cmd path). This ensures Codex picks the .cmd - // on Windows regardless of which field it reads. - const fakeRunner = '"C:/Program Files/nodejs/node.exe"'; - const result = ensureCodexHooksJsonSessionStart(tmpDir, { - absoluteRunner: fakeRunner, - platform: 'win32', - }); - // The shim write and hooks.json write should succeed in the tmp dir. - if (result.wrote) { - const hooksJson = readHooksJson(tmpDir); - const handlers = hooksJsonHandlersForEvent(hooksJson, 'SessionStart'); - assert.ok(handlers.length > 0, - `SessionStart must be registered on Windows path; got: ${JSON.stringify(hooksJson)}`); - const entry = handlers[0]; - assert.ok(typeof entry.commandWindows === 'string', - `commandWindows must be present on Windows path; got: ${JSON.stringify(entry)}`); - // commandWindows should reference the .cmd shim - assert.ok( - entry.commandWindows.includes('gsd-check-update') && entry.commandWindows.includes('.cmd'), - `commandWindows must reference gsd-check-update.cmd on win32; got: ${entry.commandWindows}` - ); - } - }); -}); - -// ─── Suite 4: idempotency ──────────────────────────────────────────────────── - -describe('enh-772: ensureCodexHooksJsonEvent is idempotent', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = createTempDir('gsd-772-idem-'); - stubHookFile(tmpDir, 'gsd-context-monitor.js'); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - for (const eventName of ['SubagentStart', 'Stop', 'PostToolUse']) { - test(`${eventName}: calling twice does not duplicate hook entries`, () => { - const fakeRunner = '"/usr/local/bin/node"'; - const opts = { absoluteRunner: fakeRunner, platform: 'linux' }; - - ensureCodexHooksJsonEvent(tmpDir, eventName, opts); - ensureCodexHooksJsonEvent(tmpDir, eventName, opts); - - const hooksJson = readHooksJson(tmpDir); - const handlers = hooksJsonHandlersForEvent(hooksJson, eventName); - assert.strictEqual(handlers.length, 1, - `${eventName} should have exactly 1 hook handler after idempotent re-register; got ${handlers.length}: ${JSON.stringify(handlers)}`); - }); - } -}); - -// ─── Suite 5: removeCodexHooksJsonEvent ────────────────────────────────────── - -describe('enh-772: removeCodexHooksJsonEvent removes managed entries', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = createTempDir('gsd-772-remove-'); - stubHookFile(tmpDir, 'gsd-context-monitor.js'); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - for (const eventName of ['SubagentStart', 'Stop', 'PostToolUse']) { - test(`${eventName}: removeCodexHooksJsonEvent removes the managed entry`, () => { - const fakeRunner = '"/usr/local/bin/node"'; - ensureCodexHooksJsonEvent(tmpDir, eventName, { - absoluteRunner: fakeRunner, - platform: 'linux', - }); - - // Verify it was registered - let hooksJson = readHooksJson(tmpDir); - let handlers = hooksJsonHandlersForEvent(hooksJson, eventName); - assert.ok(handlers.length > 0, `${eventName} must be registered before removal`); - - // Remove - const result = removeCodexHooksJsonEvent(tmpDir, eventName); - assert.ok(result.changed || result.wrote, - `removeCodexHooksJsonEvent must change hooks.json for ${eventName}`); - - hooksJson = readHooksJson(tmpDir); - if (hooksJson) { - handlers = hooksJsonHandlersForEvent(hooksJson, eventName); - assert.strictEqual(handlers.length, 0, - `After removal, ${eventName} should have 0 handlers; got: ${JSON.stringify(handlers)}`); - } - }); - } -}); - -// ─── Suite 6: reconcileCodexHooksJsonEvent preserves user entries ───────────── - -describe('enh-772: reconcileCodexHooksJsonEvent preserves user-owned entries', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = createTempDir('gsd-772-preserve-'); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('user-owned SubagentStart entry is preserved when GSD entry is registered', () => { - const hooksJsonPath = path.join(tmpDir, 'hooks.json'); - const userEntry = { - hooks: [{ type: 'command', command: 'my-custom-hook.sh' }] - }; - fs.writeFileSync(hooksJsonPath, JSON.stringify({ - SubagentStart: [userEntry] - }, null, 2) + '\n'); - - reconcileCodexHooksJsonEvent(tmpDir, 'SubagentStart', { - managedCommand: '"/usr/local/bin/node" "/home/me/.codex/hooks/gsd-context-monitor.js"', - }); - - const hooksJson = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8')); - const table = hooksJson.hooks || hooksJson; - const entries = Array.isArray(table.SubagentStart) ? table.SubagentStart : []; - // Should have 2 entries: user entry + GSD entry - assert.ok(entries.length >= 2, - `User entry must be preserved; got entries: ${JSON.stringify(entries)}`); - // User entry must still be present - const userEntryStillPresent = entries.some(e => - Array.isArray(e.hooks) && e.hooks.some(h => h.command === 'my-custom-hook.sh') - ); - assert.ok(userEntryStillPresent, - `User entry must survive GSD registration; entries: ${JSON.stringify(entries)}`); - }); -}); - }); -} - - -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/bug-2866-codex-strip-no-trailing-newline.test.cjs — consolidation epic #1969 (B8 #1977) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:bug-2866-codex-strip-no-trailing-newline (consolidation epic #1969 B8 #1977)", () => { -/** - * Bug #2866: Codex Installer (RC.7) fails to strip legacy flat hooks if - * trailing newline is missing. - * - * The cleanup regexes in `bin/install.js` matched stale GSD hook blocks - * via `\r?\n` at the end. When a stale block sat at end-of-file without - * a trailing newline (very common — many editors strip them, and the - * legacy installer never wrote one), no shape stripped, the installer - * saw `gsd-check-update` already present, skipped writing the new - * Nested-AoT block, and Codex 0.125+ refused to load with - * "invalid type: map, expected a sequence in `hooks`" - * - * Fix: every shape's terminator is now `(?:\r?\n|$)` so end-of-file - * counts as a valid terminator. The strip logic was lifted into a pure - * helper, `stripStaleGsdHookBlocks(configContent)`, exported from - * `bin/install.js` for direct test coverage. - * - * This test parses `package.json` to require `bin/install.js` - * structurally (not by hardcoded path), then drives each historical - * shape through the helper twice — once with a trailing newline, once - * without — and asserts both are stripped. - */ -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const path = require('node:path'); -const fs = require('node:fs'); - -const REPO_ROOT = path.join(__dirname, '..'); -const pkg = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, 'package.json'), 'utf-8')); -const installPath = path.resolve(REPO_ROOT, pkg.bin['gsd-core']); -const { stripStaleGsdHookBlocks } = require(installPath); - -/** - * Parse the TOML output line-structurally so assertions check shape, not - * substring presence in raw text. Comments are dropped, table headers are - * recorded, and string-valued keys are captured. Sufficient for the small, - * well-formed TOML produced by these tests. - */ -function parseTomlShape(text) { - const tableHeaders = []; - const keys = new Map(); // dotted path → string value (last-write-wins, fine for these inputs) - let currentTable = ''; - for (const rawLine of text.split('\n')) { - const line = rawLine.replace(/(?:^|\s)#.*$/, '').trim(); - if (!line) continue; - const tableMatch = line.match(/^\[(\[)?([^\]]+)\]?\]$/); - if (tableMatch) { - currentTable = tableMatch[2]; - tableHeaders.push((tableMatch[1] ? '[[' : '[') + currentTable + (tableMatch[1] ? ']]' : ']')); - continue; - } - const kvMatch = line.match(/^([A-Za-z_][\w-]*)\s*=\s*(.*)$/); - if (kvMatch) { - const key = currentTable ? `${currentTable}.${kvMatch[1]}` : kvMatch[1]; - const value = kvMatch[2].replace(/^"(.*)"$/, '$1'); - keys.set(key, value); - } - } - return { tableHeaders, keys }; -} - -const SHAPES = { - 'Shape 1 (legacy gsd-update-check)': [ - '# GSD Hooks', - '[[hooks]]', - 'event = "SessionStart"', - 'command = "node /Users/USER/.codex/hooks/gsd-update-check.js"', - ].join('\n'), - 'Shape 2 (flat [[hooks]] + gsd-check-update)': [ - '# GSD Hooks', - '[[hooks]]', - 'event = "SessionStart"', - 'command = "node /Users/USER/.codex/hooks/gsd-check-update.js"', - ].join('\n'), - 'Shape 3 ([[hooks.SessionStart]] without nested .hooks)': [ - '# GSD Hooks', - '[[hooks.SessionStart]]', - 'command = "node /Users/USER/.codex/hooks/gsd-check-update.js"', - ].join('\n'), - 'Shape 4 (nested [[hooks.SessionStart]] + [[hooks.SessionStart.hooks]])': [ - '# GSD Hooks', - '[[hooks.SessionStart]]', - '', - '[[hooks.SessionStart.hooks]]', - 'type = "command"', - 'command = "node /Users/USER/.codex/hooks/gsd-check-update.js"', - ].join('\n'), -}; - -describe('bug-2866: stripStaleGsdHookBlocks handles end-of-file without trailing newline', () => { - test('stripStaleGsdHookBlocks is exported from bin/install.js', () => { - assert.strictEqual(typeof stripStaleGsdHookBlocks, 'function', - 'bin/install.js must export stripStaleGsdHookBlocks'); - }); - - function assertStripped(out, shape, scenario) { - const shape_ = parseTomlShape(out); - const hooksTable = shape_.tableHeaders.find((h) => /^\[\[?hooks(\.|]\])/.test(h)); - assert.strictEqual(hooksTable, undefined, - `(${shape}, ${scenario}) no hooks table header may remain after strip, got tables: ${shape_.tableHeaders.join(', ')}`); - const staleCmd = [...shape_.keys.entries()].find(([_, v]) => - /gsd-(update-check|check-update)/.test(v)); - assert.strictEqual(staleCmd, undefined, - `(${shape}, ${scenario}) no key may carry a stale gsd-*-update command, got: ${staleCmd && staleCmd.join('=')}`); - assert.strictEqual(shape_.keys.get('history.persistence'), 'save-all', - `(${shape}, ${scenario}) history.persistence must be preserved as "save-all"`); - } - - for (const [shape, block] of Object.entries(SHAPES)) { - test(`${shape}: stripped when terminated by trailing newline`, () => { - const input = `[history]\npersistence = "save-all"\n${block}\n`; - assertStripped(stripStaleGsdHookBlocks(input), shape, 'with trailing newline'); - }); - - test(`${shape}: stripped when at end-of-file without trailing newline`, () => { - // The reporter's repro: stale block sits at the very end with no \n. - const input = `[history]\npersistence = "save-all"\n${block}`; - assertStripped(stripStaleGsdHookBlocks(input), shape, 'no trailing newline'); - }); - } - - test('returns input unchanged when no GSD hook block is present', () => { - const benign = '[history]\npersistence = "save-all"\n'; - const out = stripStaleGsdHookBlocks(benign); - assert.strictEqual(out, benign, 'helper must be a no-op when no GSD reference exists'); - const benignShape = parseTomlShape(out); - assert.strictEqual(benignShape.keys.get('history.persistence'), 'save-all', - 'parsed shape must preserve history.persistence'); - assert.deepStrictEqual(benignShape.tableHeaders, ['[history]'], - 'parsed shape must contain only the [history] table'); - }); - - // The structural rewrite (TOML-AST-driven, not regex-driven) must handle - // whitespace and key-ordering variations that the previous regex missed. - // These cases were silently leaked by the old implementation; one - // (V3) actually corrupted the file by leaving an orphaned key=value line - // outside any table. - const VARIATIONS = { - 'extra blank line in Shape 4': [ - '# GSD Hooks', - '[[hooks.SessionStart]]', - '', - '', - '[[hooks.SessionStart.hooks]]', - 'type = "command"', - 'command = "node /Users/USER/.codex/hooks/gsd-check-update.js"', - ].join('\n'), - 'keys reordered (command before event in Shape 2)': [ - '# GSD Hooks', - '[[hooks]]', - 'command = "node /Users/USER/.codex/hooks/gsd-check-update.js"', - 'event = "SessionStart"', - ].join('\n'), - 'extra key alongside command (Shape 3 + timeout)': [ - '# GSD Hooks', - '[[hooks.SessionStart]]', - 'command = "node /Users/USER/.codex/hooks/gsd-check-update.js"', - 'timeout = 5000', - ].join('\n'), - 'tight whitespace (no spaces around `=`)': [ - '# GSD Hooks', - '[[hooks]]', - 'event="SessionStart"', - 'command="node /Users/USER/.codex/hooks/gsd-check-update.js"', - ].join('\n'), - }; - - for (const [variation, block] of Object.entries(VARIATIONS)) { - test(`variation stripped: ${variation}`, () => { - const input = `[history]\npersistence = "save-all"\n${block}\n`; - assertStripped(stripStaleGsdHookBlocks(input), variation, 'with trailing newline'); - }); - test(`variation stripped at EOF without trailing newline: ${variation}`, () => { - const input = `[history]\npersistence = "save-all"\n${block}`; - assertStripped(stripStaleGsdHookBlocks(input), variation, 'no trailing newline'); - }); - } - - test('user-authored [[hooks.UserPromptSubmit]] is preserved', () => { - // The structural strip must not touch hook tables that don't carry a - // GSD-managed `gsd-(check-update|update-check).js` command. - const input = [ - '[history]', - 'persistence = "save-all"', - '[[hooks.UserPromptSubmit]]', - 'command = "node /Users/USER/my-hook.js"', - '', - ].join('\n'); - const out = stripStaleGsdHookBlocks(input); - const shape = parseTomlShape(out); - assert.ok( - shape.tableHeaders.includes('[[hooks.UserPromptSubmit]]'), - `user-authored [[hooks.UserPromptSubmit]] must survive, got: ${shape.tableHeaders.join(', ')}`, - ); - assert.strictEqual( - shape.keys.get('hooks.UserPromptSubmit.command'), - 'node /Users/USER/my-hook.js', - 'user-authored command value must be preserved verbatim', - ); - }); - - test('Shape 4 strip does not leave an orphaned [[hooks.SessionStart]] header', () => { - // Shape 4 is stripped before Shape 3 specifically to avoid this. - const block = SHAPES['Shape 4 (nested [[hooks.SessionStart]] + [[hooks.SessionStart.hooks]])']; - const out = stripStaleGsdHookBlocks(`[history]\npersistence = "save-all"\n${block}`); - const outShape = parseTomlShape(out); - const orphan = outShape.tableHeaders.find((h) => /hooks\.SessionStart/.test(h)); - assert.strictEqual(orphan, undefined, - `Shape 4 strip must remove the parent [[hooks.SessionStart]] header too, got tables: ${outShape.tableHeaders.join(', ')}`); - }); -}); - }); -} - -// ─── #2586: stop installing Codex context-monitor hooks without metrics ──── -// gsd-context-monitor.js reads a statusline bridge file Codex never writes, -// so every registered event was a guaranteed silent no-op. These tests drive -// the REAL install()/uninstall() entry points against a real temp CODEX_HOME -// — never a hand-fabricated manifest (see PR #2709's Blocker 1: its cleanup -// path was unreachable in production because its tests only exercised a -// fixture, not real installer state). -describe('#2586 Codex context-monitor: stop installing, clean up on reinstall', () => { - const { - cleanupOrphanedCodexContextMonitorScript, - isGsdOwnedCodexContextMonitorScript, - hooksJsonReferencesCodexContextMonitor, - } = require('../gsd-core/bin/lib/runtime-hooks-surface.cjs'); - - let codexHome; - - beforeEach(() => { - codexHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-codex-2586-')); - }); - - afterEach(() => { - cleanup(codexHome); - delete process.env.GSD_ALLOW_SYMLINKED_DEST; - }); - - function hooksJsonPath(home) { - return path.join(home, 'hooks.json'); - } - - function readHooksJson(home) { - const raw = fs.readFileSync(hooksJsonPath(home), 'utf8'); - return JSON.parse(raw); - } - - function monitorScriptPath(home) { - return path.join(home, 'hooks', 'gsd-context-monitor.js'); - } - - function monitorCmdShimPath(home) { - return path.join(home, 'hooks', 'gsd-context-monitor.cmd'); - } - - // uninstall(), unlike install(), does not sandbox CODEX_HOME/HOME itself — - // runCodexInstall's own env-restore in its `finally` means those are back - // to the real environment by the time a bare `uninstall(true, 'codex')` - // would run. Mirrors runCodexInstall's own sandboxing exactly so uninstall - // operates on the temp fixture, never the real ~/.codex. - function runCodexUninstall(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; - process.env.HOME = codexHome; - process.env.USERPROFILE = codexHome; - try { - process.chdir(cwd); - return uninstall(true, 'codex'); - } finally { - process.chdir(previousCwd); - 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; - } - } - - // The exact shape a real pre-#2586 install would have written: the shipped - // gsd-context-monitor.js content (with its ownership markers intact) plus - // hooks.json registrations for every CODEX_EXTENDED_HOOK_EVENTS member, - // using the same command-projection shape ensureCodexHooksJsonEvent used - // to write. Built from the real resolveNodeRunner() output, not a literal - // guess at the command string, so a drift in projectManagedHookCommand's - // output shape cannot make this fixture silently stop matching reality. - function seedPreExisting2586Install(home) { - fs.mkdirSync(path.join(home, 'hooks'), { recursive: true }); - // A literal fixture carrying the same ownership markers - // isGsdOwnedCodexContextMonitorScript looks for, built as a string - // (never read+string-matched from the real shipped source file — see - // this repo's "no source grep" test rule). - const fixtureContent = [ - '#!/usr/bin/env node', - '// gsd-hook-version: 1.12.0', - '// Context Monitor - PostToolUse/AfterTool hook', - 'process.exit(0);', - '', - ].join('\n'); - fs.writeFileSync(monitorScriptPath(home), fixtureContent, 'utf8'); - const runner = resolveNodeRunner(); - const hooksSurface = require('../gsd-core/bin/lib/runtime-hooks-surface.cjs'); - for (const eventName of CODEX_EXTENDED_HOOK_EVENTS) { - hooksSurface.ensureCodexHooksJsonEvent(home, eventName, { - absoluteRunner: runner, - platform: process.platform, - }); - } - } - - test('fresh install does not copy gsd-context-monitor.js or register any extended event', () => { - runCodexInstall(codexHome); - assert.strictEqual(fs.existsSync(monitorScriptPath(codexHome)), false, - 'gsd-context-monitor.js must not be copied on a fresh Codex install'); - assert.strictEqual(fs.existsSync(monitorCmdShimPath(codexHome)), false); - assert.strictEqual(fs.existsSync(path.join(codexHome, 'hooks', 'lib', 'hook-exit.js')), false, - 'hook-exit.js is only required by gsd-context-monitor.js — nothing else staged should pull it in'); - assert.ok(fs.existsSync(path.join(codexHome, 'hooks', 'gsd-check-update.js')), - 'gsd-check-update.js must still be staged'); - assert.ok(fs.existsSync(path.join(codexHome, 'hooks', 'gsd-check-update-worker.js'))); - assert.ok(fs.existsSync(path.join(codexHome, 'hooks', 'managed-hooks-registry.cjs'))); - if (fs.existsSync(hooksJsonPath(codexHome))) { - assert.strictEqual(hooksJsonReferencesCodexContextMonitor(codexHome), false); - } - }); - - test('reinstall removes exact pre-#2586 registrations for every extended event and deletes the orphaned script', () => { - runCodexInstall(codexHome); - seedPreExisting2586Install(codexHome); - assert.ok(fs.existsSync(monitorScriptPath(codexHome)), 'fixture sanity: script seeded'); - assert.strictEqual(hooksJsonReferencesCodexContextMonitor(codexHome), true, 'fixture sanity: registered'); - - runCodexInstall(codexHome); - - assert.strictEqual(fs.existsSync(monitorScriptPath(codexHome)), false, - 'orphaned gsd-context-monitor.js must be removed once unreferenced and GSD-owned'); - assert.strictEqual(hooksJsonReferencesCodexContextMonitor(codexHome), false, - 'no hooks.json entry may still reference gsd-context-monitor after reinstall'); - // gsd-check-update's own SessionStart registration must survive untouched. - const hooks = readHooksJson(codexHome); - const sessionStart = (hooks.hooks && hooks.hooks.SessionStart) || []; - const hasCheckUpdate = sessionStart.some((entry) => - (entry.hooks || []).some((h) => typeof h.command === 'string' && /gsd-check-update/.test(h.command))); - assert.ok(hasCheckUpdate, 'gsd-check-update SessionStart registration must remain after cleanup'); - }); - - test('reinstall preserves a hand-customized registration and does not delete a still-referenced script', () => { - runCodexInstall(codexHome); - seedPreExisting2586Install(codexHome); - // Hand-edit ONE event's entry to a shape isManagedHookCommand will not - // recognize (wraps the invocation in a shell script it does not know). - const before = readHooksJson(codexHome); - before.hooks.Stop = [{ hooks: [{ type: 'command', command: 'bash -c "/opt/custom/my-wrapper.sh"' }] }]; - fs.writeFileSync(hooksJsonPath(codexHome), JSON.stringify(before, null, 2) + '\n', 'utf8'); - - runCodexInstall(codexHome); - - const after = readHooksJson(codexHome); - assert.deepStrictEqual(after.hooks.Stop, before.hooks.Stop, - 'a hand-customized registration must survive verbatim'); - }); - - test('reinstall leaves unrelated hooks.json events and config.toml keys untouched', () => { - runCodexInstall(codexHome); - let hooks = fs.existsSync(hooksJsonPath(codexHome)) ? readHooksJson(codexHome) : { hooks: {} }; - if (!hooks.hooks) hooks.hooks = {}; - hooks.hooks.UnrelatedEvent = [{ hooks: [{ type: 'command', command: 'echo unrelated' }] }]; - fs.writeFileSync(hooksJsonPath(codexHome), JSON.stringify(hooks, null, 2) + '\n', 'utf8'); - const configPath = path.join(codexHome, 'config.toml'); - const configBefore = fs.readFileSync(configPath, 'utf8') + '\n[my_unrelated_section]\nfoo = "bar"\n'; - fs.writeFileSync(configPath, configBefore, 'utf8'); - - runCodexInstall(codexHome); - - const after = readHooksJson(codexHome); - assert.deepStrictEqual(after.hooks.UnrelatedEvent, hooks.hooks.UnrelatedEvent, - 'an unrelated event array must be untouched'); - const configAfter = fs.readFileSync(configPath, 'utf8'); - assert.ok(configAfter.includes('[my_unrelated_section]\nfoo = "bar"'), - 'unrelated config.toml section must survive a Codex reinstall'); - }); - - test('a user-owned pre-existing EMPTY event array is preserved, not deleted', () => { - runCodexInstall(codexHome); - fs.writeFileSync(hooksJsonPath(codexHome), JSON.stringify({ hooks: { Stop: [] } }, null, 2) + '\n', 'utf8'); - - runCodexInstall(codexHome); - - const after = readHooksJson(codexHome); - assert.ok(Array.isArray(after.hooks.Stop) && after.hooks.Stop.length === 0, - 'an empty array the user already had must not be dropped by cleanup that found nothing GSD-owned to remove'); - }); - - test('symlinked hooks.json aborts before any Codex change, without GSD_ALLOW_SYMLINKED_DEST', () => { - runCodexInstall(codexHome); - const configPath = path.join(codexHome, 'config.toml'); - const configBefore = fs.readFileSync(configPath, 'utf8'); - const realHooksJson = path.join(codexHome, 'real-hooks.json'); - fs.writeFileSync(realHooksJson, JSON.stringify({ hooks: {} }, null, 2) + '\n', 'utf8'); - fs.unlinkSync(hooksJsonPath(codexHome)); - fs.symlinkSync(realHooksJson, hooksJsonPath(codexHome)); - - assert.throws(() => runCodexInstall(codexHome), /symlink/i); - - assert.ok(fs.lstatSync(hooksJsonPath(codexHome)).isSymbolicLink(), - 'the symlink itself must survive an aborted install — never replaced by a plain file'); - assert.strictEqual(fs.readFileSync(configPath, 'utf8'), configBefore, - 'config.toml must be restored to its pre-attempt snapshot on abort'); - }); - - test('symlinked hooks.json is followed when GSD_ALLOW_SYMLINKED_DEST=1', () => { - runCodexInstall(codexHome); - const realHooksJson = path.join(codexHome, 'real-hooks.json'); - fs.writeFileSync(realHooksJson, JSON.stringify({ hooks: {} }, null, 2) + '\n', 'utf8'); - fs.unlinkSync(hooksJsonPath(codexHome)); - fs.symlinkSync(realHooksJson, hooksJsonPath(codexHome)); - process.env.GSD_ALLOW_SYMLINKED_DEST = '1'; - - assert.doesNotThrow(() => runCodexInstall(codexHome)); - - assert.ok(fs.lstatSync(hooksJsonPath(codexHome)).isSymbolicLink(), 'still a symlink afterward'); - assert.ok(fs.existsSync(realHooksJson), 'the symlink target must have been written through'); - }); - - test('cleanupOrphanedCodexContextMonitorScript keeps the hooks.json deregistration when script deletion fails', () => { - runCodexInstall(codexHome); - seedPreExisting2586Install(codexHome); - for (const eventName of CODEX_EXTENDED_HOOK_EVENTS) { - require('../gsd-core/bin/lib/runtime-hooks-surface.cjs').removeCodexHooksJsonEvent(codexHome, eventName); - } - assert.strictEqual(hooksJsonReferencesCodexContextMonitor(codexHome), false, 'deregistration committed first'); - - const originalUnlinkSync = fs.unlinkSync; - fs.unlinkSync = (target, ...rest) => { - if (typeof target === 'string' && target.includes('gsd-context-monitor')) { - throw Object.assign(new Error('EPERM: simulated'), { code: 'EPERM' }); - } - return originalUnlinkSync.call(fs, target, ...rest); - }; - // Determine which GSD-owned candidates actually exist BEFORE the mocked - // deletion attempt — on Windows, ensureCodexHooksJsonEvent also staged a - // .cmd shim alongside the .js file (see buildCodexHookWindowsShimIR), so - // both deletions fail under the mock above; on POSIX only the .js file - // exists. Asserting against this rather than a hardcoded 1 keeps the row - // meaningful on both platforms instead of just loosening it to "at least - // one" (see CI failure: Windows reported 2 warnings, not 1). - const cmdShimPath = monitorCmdShimPath(codexHome); - const cmdShimExisted = fs.existsSync(cmdShimPath); - let result; - try { - result = cleanupOrphanedCodexContextMonitorScript(codexHome); - } finally { - fs.unlinkSync = originalUnlinkSync; - } - - const expectedWarningCount = cmdShimExisted ? 2 : 1; - assert.strictEqual(result.warnings.length, expectedWarningCount); - assert.ok(result.warnings.some((w) => /gsd-context-monitor\.js$/.test(w.path)), - 'a warning must name the .js script'); - if (cmdShimExisted) { - assert.ok(result.warnings.some((w) => /gsd-context-monitor\.cmd$/.test(w.path)), - 'a warning must name the .cmd shim when Windows staged one'); - assert.ok(fs.existsSync(cmdShimPath), 'the .cmd shim remains on disk since its deletion failed too'); - } - assert.strictEqual(hooksJsonReferencesCodexContextMonitor(codexHome), false, - 'the already-safe hooks.json deregistration must not be reverted by a script-deletion failure'); - assert.ok(fs.existsSync(monitorScriptPath(codexHome)), 'the file remains on disk since deletion failed'); - }); - - test('isGsdOwnedCodexContextMonitorScript rejects a user file at the same path', () => { - runCodexInstall(codexHome); - fs.mkdirSync(path.join(codexHome, 'hooks'), { recursive: true }); - fs.writeFileSync(monitorScriptPath(codexHome), '#!/usr/bin/env node\nconsole.log("my own script");\n', 'utf8'); - assert.strictEqual(isGsdOwnedCodexContextMonitorScript(monitorScriptPath(codexHome)), false); - }); - - test('uninstall removes recognized registrations and the orphaned script symmetrically with install', () => { - runCodexInstall(codexHome); - seedPreExisting2586Install(codexHome); - - runCodexUninstall(codexHome); - - assert.strictEqual(fs.existsSync(monitorScriptPath(codexHome)), false); - if (fs.existsSync(hooksJsonPath(codexHome))) { - assert.strictEqual(hooksJsonReferencesCodexContextMonitor(codexHome), false); - } - }); - - test('uninstall does not throw on an unmodeled hooks.json event-value shape', () => { - runCodexInstall(codexHome); - fs.writeFileSync(hooksJsonPath(codexHome), JSON.stringify({ hooks: { Stop: 'not-an-array' } }, null, 2) + '\n', 'utf8'); - assert.doesNotThrow(() => runCodexUninstall(codexHome)); - }); - - test('property: reconcileCodexHooksJsonEvent never removes a non-managed-shape command', () => { - const { reconcileCodexHooksJsonEvent } = require('../gsd-core/bin/lib/runtime-hooks-surface.cjs'); - fc.assert( - fc.property( - fc.array(fc.string({ minLength: 1, maxLength: 40 }).filter((s) => !/gsd-context-monitor|gsd-check-update/.test(s)), { minLength: 1, maxLength: 5 }), - (customCommands) => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-codex-2586-prop-')); - try { - const seeded = { hooks: { Stop: [{ hooks: customCommands.map((c) => ({ type: 'command', command: c })) }] } }; - fs.writeFileSync(path.join(home, 'hooks.json'), JSON.stringify(seeded, null, 2) + '\n', 'utf8'); - reconcileCodexHooksJsonEvent(home, 'Stop', { managedCommand: null }); - const after = JSON.parse(fs.readFileSync(path.join(home, 'hooks.json'), 'utf8')); - const survivingCommands = ((after.hooks && after.hooks.Stop) || []) - .flatMap((entry) => (entry.hooks || []).map((h) => h.command)); - for (const c of customCommands) { - assert.ok(survivingCommands.includes(c), `non-managed command "${c}" must survive removal`); - } - } finally { - cleanup(home); - } - }, - ), - { numRuns: 25 }, - ); - }); -}); diff --git a/tests/compact-content-template-variant-parity.test.cjs b/tests/compact-content-template-variant-parity.test.cjs new file mode 100644 index 000000000..f95527042 --- /dev/null +++ b/tests/compact-content-template-variant-parity.test.cjs @@ -0,0 +1,142 @@ +'use strict'; + +/** + * tests/compact-content-template-variant-parity.test.cjs — ADR-4139, epic #4139, Phase 6 (#4406). + * + * Check 5 of `.gsd/phase/enhance-4406-lazy-remainder/40-design.md`'s variant-pair checklist: + * template consumer parity. Only two `gsd-core/templates/**` files got a `.compact.md` variant + * wired to a genuine runtime `Read` this phase — `summary.md` and `user-setup.md` (see + * `40-design.md`'s "a size-only candidate list was also the wrong test here" for why `spec.md` + * was dropped and why `summary.md`'s wiring is scoped to one call site only). + * + * The actual guarantee that makes compacting these templates safe: downstream consumers parse + * the GENERATED artifact (a real SUMMARY.md / USER-SETUP.md an agent wrote), never the template + * file itself. So the only way a compact variant could silently break a real consumer is if it + * changed the artifact's OUTPUT-FORMAT CONTRACT — the `## File Template` fenced block — relative + * to the canonical file. Both compact variants were authored to leave that block byte-identical + * and compact only the surrounding illustrative material (examples, guidelines prose). This test + * proves that invariant directly, rather than assuming it from the authoring process, and then + * proves the shared contract really is what the one real deterministic consumer + * (`gsd-core/bin/lib/coverage.cjs`'s `classifyContent`, backing `gsd-tools uat classify-coverage`) + * accepts and classifies correctly. + * + * `user-setup.md` has no deterministic content parser anywhere in this repo (confirmed by + * repo-wide search — the only "consumption" beyond an agent `Read` is a human `grep -r + * "USER-SETUP" .planning/` search over filenames, insensitive to internal structure). Its parity + * check is therefore limited to the File Template identity assertion; there is no real parser + * round trip to run against it. + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const ROOT = path.join(__dirname, '..'); +const { classifyContent } = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'coverage.cjs')); + +/** + * Extract the fenced code block immediately following a `## File Template` heading. + * Returns the block's inner content (without the opening/closing fences), or null if + * the heading or a following fence isn't found. + * @param {string} content + * @returns {string | null} + */ +function extractFileTemplateBlock(content) { + const headingIdx = content.indexOf('## File Template'); + if (headingIdx === -1) return null; + const fenceStart = content.indexOf('```', headingIdx); + if (fenceStart === -1) return null; + const afterOpenFence = content.indexOf('\n', fenceStart) + 1; + const fenceEnd = content.indexOf('\n```', afterOpenFence); + if (fenceEnd === -1) return null; + return content.slice(afterOpenFence, fenceEnd); +} + +describe('template consumer parity — File Template contract identity', () => { + test('summary.md and summary.compact.md share a byte-identical File Template block', () => { + const canonical = fs.readFileSync(path.join(ROOT, 'gsd-core', 'templates', 'summary.md'), 'utf8'); + const compact = fs.readFileSync(path.join(ROOT, 'gsd-core', 'templates', 'summary.compact.md'), 'utf8'); + const canonicalBlock = extractFileTemplateBlock(canonical); + const compactBlock = extractFileTemplateBlock(compact); + assert.ok(canonicalBlock, 'canonical summary.md must have an extractable File Template block'); + assert.ok(compactBlock, 'summary.compact.md must have an extractable File Template block'); + assert.strictEqual( + compactBlock, + canonicalBlock, + 'summary.compact.md must not alter the output-format contract any downstream consumer parses', + ); + }); + + test('user-setup.md and user-setup.compact.md share a byte-identical File Template block', () => { + const canonical = fs.readFileSync(path.join(ROOT, 'gsd-core', 'templates', 'user-setup.md'), 'utf8'); + const compact = fs.readFileSync(path.join(ROOT, 'gsd-core', 'templates', 'user-setup.compact.md'), 'utf8'); + const canonicalBlock = extractFileTemplateBlock(canonical); + const compactBlock = extractFileTemplateBlock(compact); + assert.ok(canonicalBlock, 'canonical user-setup.md must have an extractable File Template block'); + assert.ok(compactBlock, 'user-setup.compact.md must have an extractable File Template block'); + assert.strictEqual( + compactBlock, + canonicalBlock, + 'user-setup.compact.md must not alter the output-format contract', + ); + }); +}); + +describe('template consumer parity — real classify-coverage round trip (summary.md)', () => { + const REALISTIC_SUMMARY = `--- +phase: 07-example +plan: 01 +subsystem: testing +tags: [example] +requires: [] +provides: [] +affects: [] +coverage: + - id: D1 + description: "Deterministic deliverable" + verification: + - kind: unit + ref: "tests/example.test.cjs#does the thing" + status: pass + human_judgment: false + - id: D2 + description: "Deliverable needing a human" + verification: [] + human_judgment: true + rationale: "UI screenshot review" +duration: 5min +completed: 2026-01-01 +status: complete +--- + +# Phase 7: Example Summary + +**A deterministic deliverable and a human-judgment deliverable.** +`; + + test('a SUMMARY.md built from the shared File Template coverage schema classifies correctly', () => { + const result = classifyContent(REALISTIC_SUMMARY, '07-example-01-SUMMARY.md'); + assert.strictEqual(result.mode, 'coverage'); + assert.strictEqual(result.errors.length, 0, `expected no validation errors: ${JSON.stringify(result.errors)}`); + assert.strictEqual(result.auto_passed.length, 1, 'D1 (human_judgment:false, all verification pass) must auto-pass'); + assert.strictEqual(result.present.length, 1, 'D2 (human_judgment:true) must route to a human'); + assert.strictEqual(result.all_auto_covered, false); + }); + + test('boundary: a legacy SUMMARY.md with no coverage block still classifies (byte-identical fallback)', () => { + const legacy = REALISTIC_SUMMARY.replace(/coverage:[\s\S]*?rationale: "UI screenshot review"\n/, ''); + const result = classifyContent(legacy, '07-example-01-SUMMARY.md'); + assert.strictEqual(result.mode, 'legacy'); + }); + + test('negative: a coverage block that fails validation is presented to a human, never silently auto-passed', () => { + const malformed = REALISTIC_SUMMARY.replace('human_judgment: false', ''); + const result = classifyContent(malformed, '07-example-01-SUMMARY.md'); + assert.strictEqual(result.mode, 'coverage'); + assert.ok( + result.present.some((p) => p.id === 'D1'), + 'D1 missing human_judgment must route to present, never auto-pass', + ); + }); +}); diff --git a/tests/compact-content-variant-guard.test.cjs b/tests/compact-content-variant-guard.test.cjs new file mode 100644 index 000000000..e4e568376 --- /dev/null +++ b/tests/compact-content-variant-guard.test.cjs @@ -0,0 +1,293 @@ +'use strict'; + +/** + * tests/compact-content-variant-guard.test.cjs — ADR-4139, epic #4139, Phase 6 (#4406). + * + * Implements the four mechanical checks `.gsd/phase/enhance-4406-lazy-remainder/40-design.md` + * describes for the variant-swap shape (two independent, complete files; the gate picks which + * one gets `Read`) covering `gsd-core/workflows//{modes,steps,templates}/*.compact.md` + * and `gsd-core/templates/**\/*.compact.md`. This is a DIFFERENT shape from + * `tests/compact-content-partition-guard.test.cjs` (stream 1's spine+detail partition) — see + * `tests/helpers/compact-content-variant.cjs` for why disjointness/completeness do not apply + * here. Template consumer parity (the fifth check) lives in its own file, + * `tests/compact-content-template-variant-parity.test.cjs`, because it needs a real + * artifact-generation + real-parser round trip per template rather than a generic file-shape + * check. + * + * Each check gets a RED (deliberately broken) and GREEN (fixed) fixture pair, built against + * synthetic temp files — never against this repo's own real variants — per this repo's rule + * that a guard nobody has seen go red is not yet a guard. + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const { cleanup } = require('./helpers.cjs'); +const { + discoverRegisteredVariants, + checkRegistration, + checkReachability, + checkProtectedContentPreserved, + checkSizeSmaller, +} = require('./helpers/compact-content-variant.cjs'); + +describe('compact-content variant guard — real repo state (ADR-4139, Phase 6 #4406)', () => { + test('check 1 (registration): every .compact.md file has a canonical sibling', () => { + const pairs = discoverRegisteredVariants(); + const violations = checkRegistration(pairs); + assert.deepStrictEqual(violations, [], `registration violations: ${JSON.stringify(violations, null, 2)}`); + }); + + test('check 2 (reachability): every registered compact variant is named by at least one spine', () => { + const pairs = discoverRegisteredVariants(); + const violations = checkReachability(pairs); + assert.deepStrictEqual(violations, [], `reachability violations: ${JSON.stringify(violations, null, 2)}`); + }); + + test('check 3 (protected content preserved): every protected block in a canonical file survives in its compact sibling', () => { + const pairs = discoverRegisteredVariants(); + const violations = checkProtectedContentPreserved(pairs); + assert.deepStrictEqual(violations, [], `protected-content violations: ${JSON.stringify(violations, null, 2)}`); + }); + + test('check 4 (size smaller): every compact file is strictly smaller than its canonical sibling', () => { + const pairs = discoverRegisteredVariants(); + const violations = checkSizeSmaller(pairs); + assert.deepStrictEqual(violations, [], `size violations: ${JSON.stringify(violations, null, 2)}`); + }); + + test('non-vacuity: this repo actually has registered variant pairs to check', () => { + const pairs = discoverRegisteredVariants(); + assert.ok(pairs.length > 0, 'expected at least one registered .compact.md pair — an empty result proves nothing'); + }); +}); + +describe('failing-first fixture: check 1 (registration)', () => { + test('RED — a .compact.md file exists with no canonical sibling (orphan)', () => { + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-variant-orphan-')); + try { + const workflowsDir = path.join(tmpRoot, 'gsd-core', 'workflows', 'foo', 'steps'); + fs.mkdirSync(workflowsDir, { recursive: true }); + fs.writeFileSync(path.join(workflowsDir, 'bar.compact.md'), 'Compact content with no canonical pair.\n'); + + const pairs = discoverRegisteredVariants([path.join(tmpRoot, 'gsd-core', 'workflows')]); + const violations = checkRegistration(pairs); + assert.ok(violations.length > 0, 'expected at least one registration violation'); + assert.ok( + violations.some((v) => v.kind === 'orphan_compact_file' && v.compactPath.endsWith('bar.compact.md')), + `expected the orphan file to be named: ${JSON.stringify(violations)}`, + ); + } finally { + cleanup(tmpRoot); + } + }); + + test('GREEN — the canonical sibling is added', () => { + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-variant-orphan-ok-')); + try { + const workflowsDir = path.join(tmpRoot, 'gsd-core', 'workflows', 'foo', 'steps'); + fs.mkdirSync(workflowsDir, { recursive: true }); + fs.writeFileSync(path.join(workflowsDir, 'bar.compact.md'), 'Terser.\n'); + fs.writeFileSync(path.join(workflowsDir, 'bar.md'), 'Canonical, longer content.\n'); + + const pairs = discoverRegisteredVariants([path.join(tmpRoot, 'gsd-core', 'workflows')]); + const violations = checkRegistration(pairs); + assert.deepStrictEqual(violations, []); + } finally { + cleanup(tmpRoot); + } + }); +}); + +describe('failing-first fixture: check 2 (reachability)', () => { + test('RED — a registered pair whose compact path is never named by any spine', () => { + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-variant-unreached-')); + try { + const workflowsRoot = path.join(tmpRoot, 'gsd-core', 'workflows'); + const stepsDir = path.join(workflowsRoot, 'foo', 'steps'); + fs.mkdirSync(stepsDir, { recursive: true }); + fs.writeFileSync(path.join(stepsDir, 'bar.md'), 'Canonical, longer content.\n'); + fs.writeFileSync(path.join(stepsDir, 'bar.compact.md'), 'Terser.\n'); + fs.writeFileSync(path.join(workflowsRoot, 'foo.md'), 'Spine with entirely unrelated content and no path reference of any kind.\n'); + + const pairs = discoverRegisteredVariants([workflowsRoot]); + const violations = checkReachability(pairs, [workflowsRoot]); + assert.ok(violations.length > 0, 'expected at least one reachability violation'); + assert.ok( + violations.some((v) => v.kind === 'unreachable_compact_file' && v.compactPath.endsWith('bar.compact.md')), + `expected the unreached file to be named: ${JSON.stringify(violations)}`, + ); + } finally { + cleanup(tmpRoot); + } + }); + + test('GREEN — the spine names the compact path via the variant-resolution rule', () => { + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-variant-unreached-ok-')); + try { + const workflowsRoot = path.join(tmpRoot, 'gsd-core', 'workflows'); + const stepsDir = path.join(workflowsRoot, 'foo', 'steps'); + fs.mkdirSync(stepsDir, { recursive: true }); + fs.writeFileSync(path.join(stepsDir, 'bar.md'), 'Canonical, longer content.\n'); + fs.writeFileSync(path.join(stepsDir, 'bar.compact.md'), 'Terser.\n'); + fs.writeFileSync( + path.join(workflowsRoot, 'foo.md'), + 'Read and execute `gsd-core/workflows/foo/steps/bar.md` (or its `gsd-core/workflows/foo/steps/bar.compact.md` variant per the shared gate).\n', + ); + + const pairs = discoverRegisteredVariants([workflowsRoot]); + const violations = checkReachability(pairs, [workflowsRoot]); + assert.deepStrictEqual(violations, []); + } finally { + cleanup(tmpRoot); + } + }); + + test('a vendored path ending in the same filename does not grant false reachability', () => { + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-variant-unreached-prefix-')); + try { + const workflowsRoot = path.join(tmpRoot, 'gsd-core', 'workflows'); + const stepsDir = path.join(workflowsRoot, 'foo', 'steps'); + fs.mkdirSync(stepsDir, { recursive: true }); + fs.writeFileSync(path.join(stepsDir, 'bar.md'), 'Canonical, longer content.\n'); + fs.writeFileSync(path.join(stepsDir, 'bar.compact.md'), 'Terser.\n'); + fs.writeFileSync( + path.join(workflowsRoot, 'foo.md'), + 'This mentions vendor/foo/steps/bar.compact.md, a different tree entirely.\n', + ); + + const pairs = discoverRegisteredVariants([workflowsRoot]); + const violations = checkReachability(pairs, [workflowsRoot]); + assert.ok( + violations.some((v) => v.kind === 'unreachable_compact_file'), + 'a prefixed match must not count as reachability', + ); + } finally { + cleanup(tmpRoot); + } + }); +}); + +describe('failing-first fixture: check 3 (protected content preserved)', () => { + test('RED — a protected block in the canonical file is absent from the compact sibling', () => { + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-variant-protected-')); + try { + const workflowsRoot = path.join(tmpRoot, 'gsd-core', 'workflows', 'foo', 'steps'); + fs.mkdirSync(workflowsRoot, { recursive: true }); + fs.writeFileSync( + path.join(workflowsRoot, 'bar.md'), + 'Intro.\n\n\nNever weaken this exact guardrail sentence.\n\nMore filler text that is safe to shorten elsewhere in this file to pad it out longer than the compact sibling.\n', + ); + fs.writeFileSync(path.join(workflowsRoot, 'bar.compact.md'), 'Terser intro with the guardrail dropped.\n'); + + const pairs = discoverRegisteredVariants([path.join(tmpRoot, 'gsd-core', 'workflows')]); + const violations = checkProtectedContentPreserved(pairs); + assert.ok(violations.length > 0, 'expected at least one protected-content violation'); + assert.ok( + violations.some((v) => v.missing.includes('Never weaken this exact guardrail sentence.')), + `expected the dropped sentence to be named: ${JSON.stringify(violations)}`, + ); + } finally { + cleanup(tmpRoot); + } + }); + + test('GREEN — the guardrail sentence is preserved verbatim in the compact sibling', () => { + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-variant-protected-ok-')); + try { + const workflowsRoot = path.join(tmpRoot, 'gsd-core', 'workflows', 'foo', 'steps'); + fs.mkdirSync(workflowsRoot, { recursive: true }); + fs.writeFileSync( + path.join(workflowsRoot, 'bar.md'), + 'Intro.\n\n\nNever weaken this exact guardrail sentence.\n\nMore filler text that is safe to shorten elsewhere in this file to pad it out longer than the compact sibling.\n', + ); + fs.writeFileSync( + path.join(workflowsRoot, 'bar.compact.md'), + 'Terser intro.\n\nNever weaken this exact guardrail sentence.\n', + ); + + const pairs = discoverRegisteredVariants([path.join(tmpRoot, 'gsd-core', 'workflows')]); + const violations = checkProtectedContentPreserved(pairs); + assert.deepStrictEqual(violations, []); + } finally { + cleanup(tmpRoot); + } + }); + + test('a canonical file with no protected blocks is a correct no-op (nothing to preserve)', () => { + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-variant-protected-none-')); + try { + const workflowsRoot = path.join(tmpRoot, 'gsd-core', 'workflows', 'foo', 'steps'); + fs.mkdirSync(workflowsRoot, { recursive: true }); + fs.writeFileSync(path.join(workflowsRoot, 'bar.md'), 'Plain canonical content with no protected sentinel at all.\n'); + fs.writeFileSync(path.join(workflowsRoot, 'bar.compact.md'), 'Terser.\n'); + + const pairs = discoverRegisteredVariants([path.join(tmpRoot, 'gsd-core', 'workflows')]); + const violations = checkProtectedContentPreserved(pairs); + assert.deepStrictEqual( + violations, + [], + 'a canonical file with zero blocks has nothing to check — never a violation', + ); + } finally { + cleanup(tmpRoot); + } + }); +}); + +describe('failing-first fixture: check 4 (size smaller)', () => { + test('RED — a "compact" file the same size as its canonical sibling', () => { + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-variant-size-')); + try { + const workflowsRoot = path.join(tmpRoot, 'gsd-core', 'workflows', 'foo', 'steps'); + fs.mkdirSync(workflowsRoot, { recursive: true }); + fs.writeFileSync(path.join(workflowsRoot, 'bar.md'), 'x'.repeat(100)); + fs.writeFileSync(path.join(workflowsRoot, 'bar.compact.md'), 'y'.repeat(100)); + + const pairs = discoverRegisteredVariants([path.join(tmpRoot, 'gsd-core', 'workflows')]); + const violations = checkSizeSmaller(pairs); + assert.ok(violations.length > 0, 'expected at least one size violation'); + assert.ok( + violations.some((v) => v.kind === 'compact_not_smaller' && v.canonicalSize === 100 && v.compactSize === 100), + `expected the equal-size pair to be named: ${JSON.stringify(violations)}`, + ); + } finally { + cleanup(tmpRoot); + } + }); + + test('RED — a "compact" file one byte LARGER than its canonical sibling (boundary point)', () => { + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-variant-size-over-')); + try { + const workflowsRoot = path.join(tmpRoot, 'gsd-core', 'workflows', 'foo', 'steps'); + fs.mkdirSync(workflowsRoot, { recursive: true }); + fs.writeFileSync(path.join(workflowsRoot, 'bar.md'), 'x'.repeat(100)); + fs.writeFileSync(path.join(workflowsRoot, 'bar.compact.md'), 'y'.repeat(101)); + + const pairs = discoverRegisteredVariants([path.join(tmpRoot, 'gsd-core', 'workflows')]); + const violations = checkSizeSmaller(pairs); + assert.ok(violations.length > 0, 'expected at least one size violation'); + } finally { + cleanup(tmpRoot); + } + }); + + test('GREEN — a compact file exactly one byte smaller (boundary point)', () => { + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-variant-size-ok-')); + try { + const workflowsRoot = path.join(tmpRoot, 'gsd-core', 'workflows', 'foo', 'steps'); + fs.mkdirSync(workflowsRoot, { recursive: true }); + fs.writeFileSync(path.join(workflowsRoot, 'bar.md'), 'x'.repeat(100)); + fs.writeFileSync(path.join(workflowsRoot, 'bar.compact.md'), 'y'.repeat(99)); + + const pairs = discoverRegisteredVariants([path.join(tmpRoot, 'gsd-core', 'workflows')]); + const violations = checkSizeSmaller(pairs); + assert.deepStrictEqual(violations, []); + } finally { + cleanup(tmpRoot); + } + }); +}); diff --git a/tests/fixtures/compact-content-benchmark-baseline.json b/tests/fixtures/compact-content-benchmark-baseline.json index 391c5a5ba..0b504cff1 100644 --- a/tests/fixtures/compact-content-benchmark-baseline.json +++ b/tests/fixtures/compact-content-benchmark-baseline.json @@ -13,8 +13,8 @@ "reductionPct": 36.67 }, "docs-update": { - "offTokens": 14231, - "onTokens": 11881, + "offTokens": 14232, + "onTokens": 11882, "reductionPct": 16.51 }, "execute-phase": { @@ -39,8 +39,8 @@ } }, "aggregate": { - "offTokens": 106922, - "onTokens": 90274, + "offTokens": 106923, + "onTokens": 90275, "reductionPct": 15.57 } } diff --git a/tests/fixtures/compact-content-variant-benchmark-baseline.json b/tests/fixtures/compact-content-variant-benchmark-baseline.json new file mode 100644 index 000000000..ce30dd86e --- /dev/null +++ b/tests/fixtures/compact-content-variant-benchmark-baseline.json @@ -0,0 +1,31 @@ +{ + "schema_version": 1, + "generated_by": "scripts/benchmark-compact-content-variants.cjs", + "tokenizer": { + "name": "gpt-tokenizer", + "version": "4.0.0" + }, + "label": "PROXY-TOKENIZER DELTA — gpt-tokenizer is a stand-in; Anthropic publishes no tokenizer for Claude 3+. The on/off COMPARISON is exact under this pinned tokenizer; absolute counts are not Claude's real token counts.", + "pairs": { + "gsd-core/templates/summary.md": { + "offTokens": 2938, + "onTokens": 2299, + "reductionPct": 21.75 + }, + "gsd-core/templates/user-setup.md": { + "offTokens": 2061, + "onTokens": 1363, + "reductionPct": 33.87 + }, + "gsd-core/workflows/help/modes/full.md": { + "offTokens": 9950, + "onTokens": 6579, + "reductionPct": 33.88 + } + }, + "aggregate": { + "offTokens": 14949, + "onTokens": 10241, + "reductionPct": 31.49 + } +} diff --git a/tests/fixtures/install-tree/antigravity.json b/tests/fixtures/install-tree/antigravity.json index 281f6db87..cc183f68f 100644 --- a/tests/fixtures/install-tree/antigravity.json +++ b/tests/fixtures/install-tree/antigravity.json @@ -296,21 +296,13 @@ "gsd-core/templates/UAT.md", "gsd-core/templates/UI-SPEC.md", "gsd-core/templates/VALIDATION.md", - "gsd-core/templates/claude-md.md", "gsd-core/templates/codebase/architecture.md", - "gsd-core/templates/codebase/concerns.md", - "gsd-core/templates/codebase/conventions.md", - "gsd-core/templates/codebase/integrations.md", "gsd-core/templates/codebase/stack.md", - "gsd-core/templates/codebase/structure.md", - "gsd-core/templates/codebase/testing.md", "gsd-core/templates/config.json", "gsd-core/templates/context.md", "gsd-core/templates/continue-here.md", "gsd-core/templates/copilot-instructions.md", - "gsd-core/templates/debug-subagent-prompt.md", "gsd-core/templates/dev-preferences.md", - "gsd-core/templates/discovery.md", "gsd-core/templates/discussion-log.md", "gsd-core/templates/milestone-archive.md", "gsd-core/templates/milestone.md", @@ -331,8 +323,10 @@ "gsd-core/templates/summary-complex.md", "gsd-core/templates/summary-minimal.md", "gsd-core/templates/summary-standard.md", + "gsd-core/templates/summary.compact.md", "gsd-core/templates/summary.md", "gsd-core/templates/user-profile.md", + "gsd-core/templates/user-setup.compact.md", "gsd-core/templates/user-setup.md", "gsd-core/templates/verification-report.md", "gsd-core/workflows/_runtime-launcher.snippet.sh", @@ -412,6 +406,7 @@ "gsd-core/workflows/help.md", "gsd-core/workflows/help/modes/brief.md", "gsd-core/workflows/help/modes/default.md", + "gsd-core/workflows/help/modes/full.compact.md", "gsd-core/workflows/help/modes/full.md", "gsd-core/workflows/help/modes/topic.md", "gsd-core/workflows/import.md", diff --git a/tests/fixtures/install-tree/augment.json b/tests/fixtures/install-tree/augment.json index c5f1cefa6..b992f115c 100644 --- a/tests/fixtures/install-tree/augment.json +++ b/tests/fixtures/install-tree/augment.json @@ -368,21 +368,13 @@ "gsd-core/templates/UAT.md", "gsd-core/templates/UI-SPEC.md", "gsd-core/templates/VALIDATION.md", - "gsd-core/templates/claude-md.md", "gsd-core/templates/codebase/architecture.md", - "gsd-core/templates/codebase/concerns.md", - "gsd-core/templates/codebase/conventions.md", - "gsd-core/templates/codebase/integrations.md", "gsd-core/templates/codebase/stack.md", - "gsd-core/templates/codebase/structure.md", - "gsd-core/templates/codebase/testing.md", "gsd-core/templates/config.json", "gsd-core/templates/context.md", "gsd-core/templates/continue-here.md", "gsd-core/templates/copilot-instructions.md", - "gsd-core/templates/debug-subagent-prompt.md", "gsd-core/templates/dev-preferences.md", - "gsd-core/templates/discovery.md", "gsd-core/templates/discussion-log.md", "gsd-core/templates/milestone-archive.md", "gsd-core/templates/milestone.md", @@ -403,8 +395,10 @@ "gsd-core/templates/summary-complex.md", "gsd-core/templates/summary-minimal.md", "gsd-core/templates/summary-standard.md", + "gsd-core/templates/summary.compact.md", "gsd-core/templates/summary.md", "gsd-core/templates/user-profile.md", + "gsd-core/templates/user-setup.compact.md", "gsd-core/templates/user-setup.md", "gsd-core/templates/verification-report.md", "gsd-core/workflows/_runtime-launcher.snippet.sh", @@ -484,6 +478,7 @@ "gsd-core/workflows/help.md", "gsd-core/workflows/help/modes/brief.md", "gsd-core/workflows/help/modes/default.md", + "gsd-core/workflows/help/modes/full.compact.md", "gsd-core/workflows/help/modes/full.md", "gsd-core/workflows/help/modes/topic.md", "gsd-core/workflows/import.md", diff --git a/tests/fixtures/install-tree/claude-local.json b/tests/fixtures/install-tree/claude-local.json index 8b88b06bd..f1b618e33 100644 --- a/tests/fixtures/install-tree/claude-local.json +++ b/tests/fixtures/install-tree/claude-local.json @@ -261,21 +261,13 @@ "gsd-core/templates/UAT.md", "gsd-core/templates/UI-SPEC.md", "gsd-core/templates/VALIDATION.md", - "gsd-core/templates/claude-md.md", "gsd-core/templates/codebase/architecture.md", - "gsd-core/templates/codebase/concerns.md", - "gsd-core/templates/codebase/conventions.md", - "gsd-core/templates/codebase/integrations.md", "gsd-core/templates/codebase/stack.md", - "gsd-core/templates/codebase/structure.md", - "gsd-core/templates/codebase/testing.md", "gsd-core/templates/config.json", "gsd-core/templates/context.md", "gsd-core/templates/continue-here.md", "gsd-core/templates/copilot-instructions.md", - "gsd-core/templates/debug-subagent-prompt.md", "gsd-core/templates/dev-preferences.md", - "gsd-core/templates/discovery.md", "gsd-core/templates/discussion-log.md", "gsd-core/templates/milestone-archive.md", "gsd-core/templates/milestone.md", @@ -296,8 +288,10 @@ "gsd-core/templates/summary-complex.md", "gsd-core/templates/summary-minimal.md", "gsd-core/templates/summary-standard.md", + "gsd-core/templates/summary.compact.md", "gsd-core/templates/summary.md", "gsd-core/templates/user-profile.md", + "gsd-core/templates/user-setup.compact.md", "gsd-core/templates/user-setup.md", "gsd-core/templates/verification-report.md", "gsd-core/workflows/_runtime-launcher.snippet.sh", @@ -377,6 +371,7 @@ "gsd-core/workflows/help.md", "gsd-core/workflows/help/modes/brief.md", "gsd-core/workflows/help/modes/default.md", + "gsd-core/workflows/help/modes/full.compact.md", "gsd-core/workflows/help/modes/full.md", "gsd-core/workflows/help/modes/topic.md", "gsd-core/workflows/import.md", diff --git a/tests/fixtures/install-tree/claude.json b/tests/fixtures/install-tree/claude.json index c55e20b8d..ed3264b0b 100644 --- a/tests/fixtures/install-tree/claude.json +++ b/tests/fixtures/install-tree/claude.json @@ -296,21 +296,13 @@ "gsd-core/templates/UAT.md", "gsd-core/templates/UI-SPEC.md", "gsd-core/templates/VALIDATION.md", - "gsd-core/templates/claude-md.md", "gsd-core/templates/codebase/architecture.md", - "gsd-core/templates/codebase/concerns.md", - "gsd-core/templates/codebase/conventions.md", - "gsd-core/templates/codebase/integrations.md", "gsd-core/templates/codebase/stack.md", - "gsd-core/templates/codebase/structure.md", - "gsd-core/templates/codebase/testing.md", "gsd-core/templates/config.json", "gsd-core/templates/context.md", "gsd-core/templates/continue-here.md", "gsd-core/templates/copilot-instructions.md", - "gsd-core/templates/debug-subagent-prompt.md", "gsd-core/templates/dev-preferences.md", - "gsd-core/templates/discovery.md", "gsd-core/templates/discussion-log.md", "gsd-core/templates/milestone-archive.md", "gsd-core/templates/milestone.md", @@ -331,8 +323,10 @@ "gsd-core/templates/summary-complex.md", "gsd-core/templates/summary-minimal.md", "gsd-core/templates/summary-standard.md", + "gsd-core/templates/summary.compact.md", "gsd-core/templates/summary.md", "gsd-core/templates/user-profile.md", + "gsd-core/templates/user-setup.compact.md", "gsd-core/templates/user-setup.md", "gsd-core/templates/verification-report.md", "gsd-core/workflows/_runtime-launcher.snippet.sh", @@ -412,6 +406,7 @@ "gsd-core/workflows/help.md", "gsd-core/workflows/help/modes/brief.md", "gsd-core/workflows/help/modes/default.md", + "gsd-core/workflows/help/modes/full.compact.md", "gsd-core/workflows/help/modes/full.md", "gsd-core/workflows/help/modes/topic.md", "gsd-core/workflows/import.md", diff --git a/tests/fixtures/install-tree/cline.json b/tests/fixtures/install-tree/cline.json index 85c714e36..984237fee 100644 --- a/tests/fixtures/install-tree/cline.json +++ b/tests/fixtures/install-tree/cline.json @@ -298,21 +298,13 @@ "gsd-core/templates/UAT.md", "gsd-core/templates/UI-SPEC.md", "gsd-core/templates/VALIDATION.md", - "gsd-core/templates/claude-md.md", "gsd-core/templates/codebase/architecture.md", - "gsd-core/templates/codebase/concerns.md", - "gsd-core/templates/codebase/conventions.md", - "gsd-core/templates/codebase/integrations.md", "gsd-core/templates/codebase/stack.md", - "gsd-core/templates/codebase/structure.md", - "gsd-core/templates/codebase/testing.md", "gsd-core/templates/config.json", "gsd-core/templates/context.md", "gsd-core/templates/continue-here.md", "gsd-core/templates/copilot-instructions.md", - "gsd-core/templates/debug-subagent-prompt.md", "gsd-core/templates/dev-preferences.md", - "gsd-core/templates/discovery.md", "gsd-core/templates/discussion-log.md", "gsd-core/templates/milestone-archive.md", "gsd-core/templates/milestone.md", @@ -333,8 +325,10 @@ "gsd-core/templates/summary-complex.md", "gsd-core/templates/summary-minimal.md", "gsd-core/templates/summary-standard.md", + "gsd-core/templates/summary.compact.md", "gsd-core/templates/summary.md", "gsd-core/templates/user-profile.md", + "gsd-core/templates/user-setup.compact.md", "gsd-core/templates/user-setup.md", "gsd-core/templates/verification-report.md", "gsd-core/workflows/_runtime-launcher.snippet.sh", @@ -414,6 +408,7 @@ "gsd-core/workflows/help.md", "gsd-core/workflows/help/modes/brief.md", "gsd-core/workflows/help/modes/default.md", + "gsd-core/workflows/help/modes/full.compact.md", "gsd-core/workflows/help/modes/full.md", "gsd-core/workflows/help/modes/topic.md", "gsd-core/workflows/import.md", diff --git a/tests/fixtures/install-tree/codebuddy.json b/tests/fixtures/install-tree/codebuddy.json index 8af8e2566..70b80da79 100644 --- a/tests/fixtures/install-tree/codebuddy.json +++ b/tests/fixtures/install-tree/codebuddy.json @@ -368,21 +368,13 @@ "gsd-core/templates/UAT.md", "gsd-core/templates/UI-SPEC.md", "gsd-core/templates/VALIDATION.md", - "gsd-core/templates/claude-md.md", "gsd-core/templates/codebase/architecture.md", - "gsd-core/templates/codebase/concerns.md", - "gsd-core/templates/codebase/conventions.md", - "gsd-core/templates/codebase/integrations.md", "gsd-core/templates/codebase/stack.md", - "gsd-core/templates/codebase/structure.md", - "gsd-core/templates/codebase/testing.md", "gsd-core/templates/config.json", "gsd-core/templates/context.md", "gsd-core/templates/continue-here.md", "gsd-core/templates/copilot-instructions.md", - "gsd-core/templates/debug-subagent-prompt.md", "gsd-core/templates/dev-preferences.md", - "gsd-core/templates/discovery.md", "gsd-core/templates/discussion-log.md", "gsd-core/templates/milestone-archive.md", "gsd-core/templates/milestone.md", @@ -403,8 +395,10 @@ "gsd-core/templates/summary-complex.md", "gsd-core/templates/summary-minimal.md", "gsd-core/templates/summary-standard.md", + "gsd-core/templates/summary.compact.md", "gsd-core/templates/summary.md", "gsd-core/templates/user-profile.md", + "gsd-core/templates/user-setup.compact.md", "gsd-core/templates/user-setup.md", "gsd-core/templates/verification-report.md", "gsd-core/workflows/_runtime-launcher.snippet.sh", @@ -484,6 +478,7 @@ "gsd-core/workflows/help.md", "gsd-core/workflows/help/modes/brief.md", "gsd-core/workflows/help/modes/default.md", + "gsd-core/workflows/help/modes/full.compact.md", "gsd-core/workflows/help/modes/full.md", "gsd-core/workflows/help/modes/topic.md", "gsd-core/workflows/import.md", diff --git a/tests/fixtures/install-tree/codex.json b/tests/fixtures/install-tree/codex.json index 1b62fc4db..5207c1e72 100644 --- a/tests/fixtures/install-tree/codex.json +++ b/tests/fixtures/install-tree/codex.json @@ -332,21 +332,13 @@ "gsd-core/templates/UAT.md", "gsd-core/templates/UI-SPEC.md", "gsd-core/templates/VALIDATION.md", - "gsd-core/templates/claude-md.md", "gsd-core/templates/codebase/architecture.md", - "gsd-core/templates/codebase/concerns.md", - "gsd-core/templates/codebase/conventions.md", - "gsd-core/templates/codebase/integrations.md", "gsd-core/templates/codebase/stack.md", - "gsd-core/templates/codebase/structure.md", - "gsd-core/templates/codebase/testing.md", "gsd-core/templates/config.json", "gsd-core/templates/context.md", "gsd-core/templates/continue-here.md", "gsd-core/templates/copilot-instructions.md", - "gsd-core/templates/debug-subagent-prompt.md", "gsd-core/templates/dev-preferences.md", - "gsd-core/templates/discovery.md", "gsd-core/templates/discussion-log.md", "gsd-core/templates/milestone-archive.md", "gsd-core/templates/milestone.md", @@ -367,8 +359,10 @@ "gsd-core/templates/summary-complex.md", "gsd-core/templates/summary-minimal.md", "gsd-core/templates/summary-standard.md", + "gsd-core/templates/summary.compact.md", "gsd-core/templates/summary.md", "gsd-core/templates/user-profile.md", + "gsd-core/templates/user-setup.compact.md", "gsd-core/templates/user-setup.md", "gsd-core/templates/verification-report.md", "gsd-core/workflows/_runtime-launcher.snippet.sh", @@ -448,6 +442,7 @@ "gsd-core/workflows/help.md", "gsd-core/workflows/help/modes/brief.md", "gsd-core/workflows/help/modes/default.md", + "gsd-core/workflows/help/modes/full.compact.md", "gsd-core/workflows/help/modes/full.md", "gsd-core/workflows/help/modes/topic.md", "gsd-core/workflows/import.md", diff --git a/tests/fixtures/install-tree/copilot.json b/tests/fixtures/install-tree/copilot.json index 62527e2fb..0595dfafd 100644 --- a/tests/fixtures/install-tree/copilot.json +++ b/tests/fixtures/install-tree/copilot.json @@ -297,21 +297,13 @@ "gsd-core/templates/UAT.md", "gsd-core/templates/UI-SPEC.md", "gsd-core/templates/VALIDATION.md", - "gsd-core/templates/claude-md.md", "gsd-core/templates/codebase/architecture.md", - "gsd-core/templates/codebase/concerns.md", - "gsd-core/templates/codebase/conventions.md", - "gsd-core/templates/codebase/integrations.md", "gsd-core/templates/codebase/stack.md", - "gsd-core/templates/codebase/structure.md", - "gsd-core/templates/codebase/testing.md", "gsd-core/templates/config.json", "gsd-core/templates/context.md", "gsd-core/templates/continue-here.md", "gsd-core/templates/copilot-instructions.md", - "gsd-core/templates/debug-subagent-prompt.md", "gsd-core/templates/dev-preferences.md", - "gsd-core/templates/discovery.md", "gsd-core/templates/discussion-log.md", "gsd-core/templates/milestone-archive.md", "gsd-core/templates/milestone.md", @@ -332,8 +324,10 @@ "gsd-core/templates/summary-complex.md", "gsd-core/templates/summary-minimal.md", "gsd-core/templates/summary-standard.md", + "gsd-core/templates/summary.compact.md", "gsd-core/templates/summary.md", "gsd-core/templates/user-profile.md", + "gsd-core/templates/user-setup.compact.md", "gsd-core/templates/user-setup.md", "gsd-core/templates/verification-report.md", "gsd-core/workflows/_runtime-launcher.snippet.sh", @@ -413,6 +407,7 @@ "gsd-core/workflows/help.md", "gsd-core/workflows/help/modes/brief.md", "gsd-core/workflows/help/modes/default.md", + "gsd-core/workflows/help/modes/full.compact.md", "gsd-core/workflows/help/modes/full.md", "gsd-core/workflows/help/modes/topic.md", "gsd-core/workflows/import.md", diff --git a/tests/fixtures/install-tree/cursor.json b/tests/fixtures/install-tree/cursor.json index b3e099a91..3224fdbbe 100644 --- a/tests/fixtures/install-tree/cursor.json +++ b/tests/fixtures/install-tree/cursor.json @@ -296,21 +296,13 @@ "gsd-core/templates/UAT.md", "gsd-core/templates/UI-SPEC.md", "gsd-core/templates/VALIDATION.md", - "gsd-core/templates/claude-md.md", "gsd-core/templates/codebase/architecture.md", - "gsd-core/templates/codebase/concerns.md", - "gsd-core/templates/codebase/conventions.md", - "gsd-core/templates/codebase/integrations.md", "gsd-core/templates/codebase/stack.md", - "gsd-core/templates/codebase/structure.md", - "gsd-core/templates/codebase/testing.md", "gsd-core/templates/config.json", "gsd-core/templates/context.md", "gsd-core/templates/continue-here.md", "gsd-core/templates/copilot-instructions.md", - "gsd-core/templates/debug-subagent-prompt.md", "gsd-core/templates/dev-preferences.md", - "gsd-core/templates/discovery.md", "gsd-core/templates/discussion-log.md", "gsd-core/templates/milestone-archive.md", "gsd-core/templates/milestone.md", @@ -331,8 +323,10 @@ "gsd-core/templates/summary-complex.md", "gsd-core/templates/summary-minimal.md", "gsd-core/templates/summary-standard.md", + "gsd-core/templates/summary.compact.md", "gsd-core/templates/summary.md", "gsd-core/templates/user-profile.md", + "gsd-core/templates/user-setup.compact.md", "gsd-core/templates/user-setup.md", "gsd-core/templates/verification-report.md", "gsd-core/workflows/_runtime-launcher.snippet.sh", @@ -412,6 +406,7 @@ "gsd-core/workflows/help.md", "gsd-core/workflows/help/modes/brief.md", "gsd-core/workflows/help/modes/default.md", + "gsd-core/workflows/help/modes/full.compact.md", "gsd-core/workflows/help/modes/full.md", "gsd-core/workflows/help/modes/topic.md", "gsd-core/workflows/import.md", diff --git a/tests/fixtures/install-tree/hermes.json b/tests/fixtures/install-tree/hermes.json index 5d6c6b794..c3fa84602 100644 --- a/tests/fixtures/install-tree/hermes.json +++ b/tests/fixtures/install-tree/hermes.json @@ -296,21 +296,13 @@ "gsd-core/templates/UAT.md", "gsd-core/templates/UI-SPEC.md", "gsd-core/templates/VALIDATION.md", - "gsd-core/templates/claude-md.md", "gsd-core/templates/codebase/architecture.md", - "gsd-core/templates/codebase/concerns.md", - "gsd-core/templates/codebase/conventions.md", - "gsd-core/templates/codebase/integrations.md", "gsd-core/templates/codebase/stack.md", - "gsd-core/templates/codebase/structure.md", - "gsd-core/templates/codebase/testing.md", "gsd-core/templates/config.json", "gsd-core/templates/context.md", "gsd-core/templates/continue-here.md", "gsd-core/templates/copilot-instructions.md", - "gsd-core/templates/debug-subagent-prompt.md", "gsd-core/templates/dev-preferences.md", - "gsd-core/templates/discovery.md", "gsd-core/templates/discussion-log.md", "gsd-core/templates/milestone-archive.md", "gsd-core/templates/milestone.md", @@ -331,8 +323,10 @@ "gsd-core/templates/summary-complex.md", "gsd-core/templates/summary-minimal.md", "gsd-core/templates/summary-standard.md", + "gsd-core/templates/summary.compact.md", "gsd-core/templates/summary.md", "gsd-core/templates/user-profile.md", + "gsd-core/templates/user-setup.compact.md", "gsd-core/templates/user-setup.md", "gsd-core/templates/verification-report.md", "gsd-core/workflows/_runtime-launcher.snippet.sh", @@ -412,6 +406,7 @@ "gsd-core/workflows/help.md", "gsd-core/workflows/help/modes/brief.md", "gsd-core/workflows/help/modes/default.md", + "gsd-core/workflows/help/modes/full.compact.md", "gsd-core/workflows/help/modes/full.md", "gsd-core/workflows/help/modes/topic.md", "gsd-core/workflows/import.md", diff --git a/tests/fixtures/install-tree/kilo.json b/tests/fixtures/install-tree/kilo.json index 6f08ecf18..57ae55781 100644 --- a/tests/fixtures/install-tree/kilo.json +++ b/tests/fixtures/install-tree/kilo.json @@ -368,21 +368,13 @@ "gsd-core/templates/UAT.md", "gsd-core/templates/UI-SPEC.md", "gsd-core/templates/VALIDATION.md", - "gsd-core/templates/claude-md.md", "gsd-core/templates/codebase/architecture.md", - "gsd-core/templates/codebase/concerns.md", - "gsd-core/templates/codebase/conventions.md", - "gsd-core/templates/codebase/integrations.md", "gsd-core/templates/codebase/stack.md", - "gsd-core/templates/codebase/structure.md", - "gsd-core/templates/codebase/testing.md", "gsd-core/templates/config.json", "gsd-core/templates/context.md", "gsd-core/templates/continue-here.md", "gsd-core/templates/copilot-instructions.md", - "gsd-core/templates/debug-subagent-prompt.md", "gsd-core/templates/dev-preferences.md", - "gsd-core/templates/discovery.md", "gsd-core/templates/discussion-log.md", "gsd-core/templates/milestone-archive.md", "gsd-core/templates/milestone.md", @@ -403,8 +395,10 @@ "gsd-core/templates/summary-complex.md", "gsd-core/templates/summary-minimal.md", "gsd-core/templates/summary-standard.md", + "gsd-core/templates/summary.compact.md", "gsd-core/templates/summary.md", "gsd-core/templates/user-profile.md", + "gsd-core/templates/user-setup.compact.md", "gsd-core/templates/user-setup.md", "gsd-core/templates/verification-report.md", "gsd-core/workflows/_runtime-launcher.snippet.sh", @@ -484,6 +478,7 @@ "gsd-core/workflows/help.md", "gsd-core/workflows/help/modes/brief.md", "gsd-core/workflows/help/modes/default.md", + "gsd-core/workflows/help/modes/full.compact.md", "gsd-core/workflows/help/modes/full.md", "gsd-core/workflows/help/modes/topic.md", "gsd-core/workflows/import.md", diff --git a/tests/fixtures/install-tree/kimi-code.json b/tests/fixtures/install-tree/kimi-code.json index cda4c483c..6b349c943 100644 --- a/tests/fixtures/install-tree/kimi-code.json +++ b/tests/fixtures/install-tree/kimi-code.json @@ -297,21 +297,13 @@ "gsd-core/templates/UAT.md", "gsd-core/templates/UI-SPEC.md", "gsd-core/templates/VALIDATION.md", - "gsd-core/templates/claude-md.md", "gsd-core/templates/codebase/architecture.md", - "gsd-core/templates/codebase/concerns.md", - "gsd-core/templates/codebase/conventions.md", - "gsd-core/templates/codebase/integrations.md", "gsd-core/templates/codebase/stack.md", - "gsd-core/templates/codebase/structure.md", - "gsd-core/templates/codebase/testing.md", "gsd-core/templates/config.json", "gsd-core/templates/context.md", "gsd-core/templates/continue-here.md", "gsd-core/templates/copilot-instructions.md", - "gsd-core/templates/debug-subagent-prompt.md", "gsd-core/templates/dev-preferences.md", - "gsd-core/templates/discovery.md", "gsd-core/templates/discussion-log.md", "gsd-core/templates/milestone-archive.md", "gsd-core/templates/milestone.md", @@ -332,8 +324,10 @@ "gsd-core/templates/summary-complex.md", "gsd-core/templates/summary-minimal.md", "gsd-core/templates/summary-standard.md", + "gsd-core/templates/summary.compact.md", "gsd-core/templates/summary.md", "gsd-core/templates/user-profile.md", + "gsd-core/templates/user-setup.compact.md", "gsd-core/templates/user-setup.md", "gsd-core/templates/verification-report.md", "gsd-core/workflows/_runtime-launcher.snippet.sh", @@ -413,6 +407,7 @@ "gsd-core/workflows/help.md", "gsd-core/workflows/help/modes/brief.md", "gsd-core/workflows/help/modes/default.md", + "gsd-core/workflows/help/modes/full.compact.md", "gsd-core/workflows/help/modes/full.md", "gsd-core/workflows/help/modes/topic.md", "gsd-core/workflows/import.md", diff --git a/tests/fixtures/install-tree/kimi.json b/tests/fixtures/install-tree/kimi.json index 40551bbe4..96768ce91 100644 --- a/tests/fixtures/install-tree/kimi.json +++ b/tests/fixtures/install-tree/kimi.json @@ -333,21 +333,13 @@ "gsd-core/templates/UAT.md", "gsd-core/templates/UI-SPEC.md", "gsd-core/templates/VALIDATION.md", - "gsd-core/templates/claude-md.md", "gsd-core/templates/codebase/architecture.md", - "gsd-core/templates/codebase/concerns.md", - "gsd-core/templates/codebase/conventions.md", - "gsd-core/templates/codebase/integrations.md", "gsd-core/templates/codebase/stack.md", - "gsd-core/templates/codebase/structure.md", - "gsd-core/templates/codebase/testing.md", "gsd-core/templates/config.json", "gsd-core/templates/context.md", "gsd-core/templates/continue-here.md", "gsd-core/templates/copilot-instructions.md", - "gsd-core/templates/debug-subagent-prompt.md", "gsd-core/templates/dev-preferences.md", - "gsd-core/templates/discovery.md", "gsd-core/templates/discussion-log.md", "gsd-core/templates/milestone-archive.md", "gsd-core/templates/milestone.md", @@ -368,8 +360,10 @@ "gsd-core/templates/summary-complex.md", "gsd-core/templates/summary-minimal.md", "gsd-core/templates/summary-standard.md", + "gsd-core/templates/summary.compact.md", "gsd-core/templates/summary.md", "gsd-core/templates/user-profile.md", + "gsd-core/templates/user-setup.compact.md", "gsd-core/templates/user-setup.md", "gsd-core/templates/verification-report.md", "gsd-core/workflows/_runtime-launcher.snippet.sh", @@ -449,6 +443,7 @@ "gsd-core/workflows/help.md", "gsd-core/workflows/help/modes/brief.md", "gsd-core/workflows/help/modes/default.md", + "gsd-core/workflows/help/modes/full.compact.md", "gsd-core/workflows/help/modes/full.md", "gsd-core/workflows/help/modes/topic.md", "gsd-core/workflows/import.md", diff --git a/tests/fixtures/install-tree/opencode.json b/tests/fixtures/install-tree/opencode.json index ee5eaf320..90037c0f8 100644 --- a/tests/fixtures/install-tree/opencode.json +++ b/tests/fixtures/install-tree/opencode.json @@ -368,21 +368,13 @@ "gsd-core/templates/UAT.md", "gsd-core/templates/UI-SPEC.md", "gsd-core/templates/VALIDATION.md", - "gsd-core/templates/claude-md.md", "gsd-core/templates/codebase/architecture.md", - "gsd-core/templates/codebase/concerns.md", - "gsd-core/templates/codebase/conventions.md", - "gsd-core/templates/codebase/integrations.md", "gsd-core/templates/codebase/stack.md", - "gsd-core/templates/codebase/structure.md", - "gsd-core/templates/codebase/testing.md", "gsd-core/templates/config.json", "gsd-core/templates/context.md", "gsd-core/templates/continue-here.md", "gsd-core/templates/copilot-instructions.md", - "gsd-core/templates/debug-subagent-prompt.md", "gsd-core/templates/dev-preferences.md", - "gsd-core/templates/discovery.md", "gsd-core/templates/discussion-log.md", "gsd-core/templates/milestone-archive.md", "gsd-core/templates/milestone.md", @@ -403,8 +395,10 @@ "gsd-core/templates/summary-complex.md", "gsd-core/templates/summary-minimal.md", "gsd-core/templates/summary-standard.md", + "gsd-core/templates/summary.compact.md", "gsd-core/templates/summary.md", "gsd-core/templates/user-profile.md", + "gsd-core/templates/user-setup.compact.md", "gsd-core/templates/user-setup.md", "gsd-core/templates/verification-report.md", "gsd-core/workflows/_runtime-launcher.snippet.sh", @@ -484,6 +478,7 @@ "gsd-core/workflows/help.md", "gsd-core/workflows/help/modes/brief.md", "gsd-core/workflows/help/modes/default.md", + "gsd-core/workflows/help/modes/full.compact.md", "gsd-core/workflows/help/modes/full.md", "gsd-core/workflows/help/modes/topic.md", "gsd-core/workflows/import.md", diff --git a/tests/fixtures/install-tree/pi.json b/tests/fixtures/install-tree/pi.json index d7ee16a43..d595d2517 100644 --- a/tests/fixtures/install-tree/pi.json +++ b/tests/fixtures/install-tree/pi.json @@ -156,21 +156,13 @@ "gsd-core/templates/UAT.md", "gsd-core/templates/UI-SPEC.md", "gsd-core/templates/VALIDATION.md", - "gsd-core/templates/claude-md.md", "gsd-core/templates/codebase/architecture.md", - "gsd-core/templates/codebase/concerns.md", - "gsd-core/templates/codebase/conventions.md", - "gsd-core/templates/codebase/integrations.md", "gsd-core/templates/codebase/stack.md", - "gsd-core/templates/codebase/structure.md", - "gsd-core/templates/codebase/testing.md", "gsd-core/templates/config.json", "gsd-core/templates/context.md", "gsd-core/templates/continue-here.md", "gsd-core/templates/copilot-instructions.md", - "gsd-core/templates/debug-subagent-prompt.md", "gsd-core/templates/dev-preferences.md", - "gsd-core/templates/discovery.md", "gsd-core/templates/discussion-log.md", "gsd-core/templates/milestone-archive.md", "gsd-core/templates/milestone.md", @@ -191,8 +183,10 @@ "gsd-core/templates/summary-complex.md", "gsd-core/templates/summary-minimal.md", "gsd-core/templates/summary-standard.md", + "gsd-core/templates/summary.compact.md", "gsd-core/templates/summary.md", "gsd-core/templates/user-profile.md", + "gsd-core/templates/user-setup.compact.md", "gsd-core/templates/user-setup.md", "gsd-core/templates/verification-report.md", "gsd-core/workflows/_runtime-launcher.snippet.sh", @@ -272,6 +266,7 @@ "gsd-core/workflows/help.md", "gsd-core/workflows/help/modes/brief.md", "gsd-core/workflows/help/modes/default.md", + "gsd-core/workflows/help/modes/full.compact.md", "gsd-core/workflows/help/modes/full.md", "gsd-core/workflows/help/modes/topic.md", "gsd-core/workflows/import.md", diff --git a/tests/fixtures/install-tree/qwen.json b/tests/fixtures/install-tree/qwen.json index 2c7865ee1..70bb5c5f5 100644 --- a/tests/fixtures/install-tree/qwen.json +++ b/tests/fixtures/install-tree/qwen.json @@ -296,21 +296,13 @@ "gsd-core/templates/UAT.md", "gsd-core/templates/UI-SPEC.md", "gsd-core/templates/VALIDATION.md", - "gsd-core/templates/claude-md.md", "gsd-core/templates/codebase/architecture.md", - "gsd-core/templates/codebase/concerns.md", - "gsd-core/templates/codebase/conventions.md", - "gsd-core/templates/codebase/integrations.md", "gsd-core/templates/codebase/stack.md", - "gsd-core/templates/codebase/structure.md", - "gsd-core/templates/codebase/testing.md", "gsd-core/templates/config.json", "gsd-core/templates/context.md", "gsd-core/templates/continue-here.md", "gsd-core/templates/copilot-instructions.md", - "gsd-core/templates/debug-subagent-prompt.md", "gsd-core/templates/dev-preferences.md", - "gsd-core/templates/discovery.md", "gsd-core/templates/discussion-log.md", "gsd-core/templates/milestone-archive.md", "gsd-core/templates/milestone.md", @@ -331,8 +323,10 @@ "gsd-core/templates/summary-complex.md", "gsd-core/templates/summary-minimal.md", "gsd-core/templates/summary-standard.md", + "gsd-core/templates/summary.compact.md", "gsd-core/templates/summary.md", "gsd-core/templates/user-profile.md", + "gsd-core/templates/user-setup.compact.md", "gsd-core/templates/user-setup.md", "gsd-core/templates/verification-report.md", "gsd-core/workflows/_runtime-launcher.snippet.sh", @@ -412,6 +406,7 @@ "gsd-core/workflows/help.md", "gsd-core/workflows/help/modes/brief.md", "gsd-core/workflows/help/modes/default.md", + "gsd-core/workflows/help/modes/full.compact.md", "gsd-core/workflows/help/modes/full.md", "gsd-core/workflows/help/modes/topic.md", "gsd-core/workflows/import.md", diff --git a/tests/fixtures/install-tree/trae.json b/tests/fixtures/install-tree/trae.json index e1857a26b..2e94faba8 100644 --- a/tests/fixtures/install-tree/trae.json +++ b/tests/fixtures/install-tree/trae.json @@ -296,21 +296,13 @@ "gsd-core/templates/UAT.md", "gsd-core/templates/UI-SPEC.md", "gsd-core/templates/VALIDATION.md", - "gsd-core/templates/claude-md.md", "gsd-core/templates/codebase/architecture.md", - "gsd-core/templates/codebase/concerns.md", - "gsd-core/templates/codebase/conventions.md", - "gsd-core/templates/codebase/integrations.md", "gsd-core/templates/codebase/stack.md", - "gsd-core/templates/codebase/structure.md", - "gsd-core/templates/codebase/testing.md", "gsd-core/templates/config.json", "gsd-core/templates/context.md", "gsd-core/templates/continue-here.md", "gsd-core/templates/copilot-instructions.md", - "gsd-core/templates/debug-subagent-prompt.md", "gsd-core/templates/dev-preferences.md", - "gsd-core/templates/discovery.md", "gsd-core/templates/discussion-log.md", "gsd-core/templates/milestone-archive.md", "gsd-core/templates/milestone.md", @@ -331,8 +323,10 @@ "gsd-core/templates/summary-complex.md", "gsd-core/templates/summary-minimal.md", "gsd-core/templates/summary-standard.md", + "gsd-core/templates/summary.compact.md", "gsd-core/templates/summary.md", "gsd-core/templates/user-profile.md", + "gsd-core/templates/user-setup.compact.md", "gsd-core/templates/user-setup.md", "gsd-core/templates/verification-report.md", "gsd-core/workflows/_runtime-launcher.snippet.sh", @@ -412,6 +406,7 @@ "gsd-core/workflows/help.md", "gsd-core/workflows/help/modes/brief.md", "gsd-core/workflows/help/modes/default.md", + "gsd-core/workflows/help/modes/full.compact.md", "gsd-core/workflows/help/modes/full.md", "gsd-core/workflows/help/modes/topic.md", "gsd-core/workflows/import.md", diff --git a/tests/fixtures/install-tree/windsurf.json b/tests/fixtures/install-tree/windsurf.json index 29d2d2426..507dcad99 100644 --- a/tests/fixtures/install-tree/windsurf.json +++ b/tests/fixtures/install-tree/windsurf.json @@ -224,21 +224,13 @@ "gsd-core/templates/UAT.md", "gsd-core/templates/UI-SPEC.md", "gsd-core/templates/VALIDATION.md", - "gsd-core/templates/claude-md.md", "gsd-core/templates/codebase/architecture.md", - "gsd-core/templates/codebase/concerns.md", - "gsd-core/templates/codebase/conventions.md", - "gsd-core/templates/codebase/integrations.md", "gsd-core/templates/codebase/stack.md", - "gsd-core/templates/codebase/structure.md", - "gsd-core/templates/codebase/testing.md", "gsd-core/templates/config.json", "gsd-core/templates/context.md", "gsd-core/templates/continue-here.md", "gsd-core/templates/copilot-instructions.md", - "gsd-core/templates/debug-subagent-prompt.md", "gsd-core/templates/dev-preferences.md", - "gsd-core/templates/discovery.md", "gsd-core/templates/discussion-log.md", "gsd-core/templates/milestone-archive.md", "gsd-core/templates/milestone.md", @@ -259,8 +251,10 @@ "gsd-core/templates/summary-complex.md", "gsd-core/templates/summary-minimal.md", "gsd-core/templates/summary-standard.md", + "gsd-core/templates/summary.compact.md", "gsd-core/templates/summary.md", "gsd-core/templates/user-profile.md", + "gsd-core/templates/user-setup.compact.md", "gsd-core/templates/user-setup.md", "gsd-core/templates/verification-report.md", "gsd-core/workflows/_runtime-launcher.snippet.sh", @@ -340,6 +334,7 @@ "gsd-core/workflows/help.md", "gsd-core/workflows/help/modes/brief.md", "gsd-core/workflows/help/modes/default.md", + "gsd-core/workflows/help/modes/full.compact.md", "gsd-core/workflows/help/modes/full.md", "gsd-core/workflows/help/modes/topic.md", "gsd-core/workflows/import.md", diff --git a/tests/fixtures/install-tree/zcode.json b/tests/fixtures/install-tree/zcode.json index b48528e46..5cfa9c9a0 100644 --- a/tests/fixtures/install-tree/zcode.json +++ b/tests/fixtures/install-tree/zcode.json @@ -368,21 +368,13 @@ "gsd-core/templates/UAT.md", "gsd-core/templates/UI-SPEC.md", "gsd-core/templates/VALIDATION.md", - "gsd-core/templates/claude-md.md", "gsd-core/templates/codebase/architecture.md", - "gsd-core/templates/codebase/concerns.md", - "gsd-core/templates/codebase/conventions.md", - "gsd-core/templates/codebase/integrations.md", "gsd-core/templates/codebase/stack.md", - "gsd-core/templates/codebase/structure.md", - "gsd-core/templates/codebase/testing.md", "gsd-core/templates/config.json", "gsd-core/templates/context.md", "gsd-core/templates/continue-here.md", "gsd-core/templates/copilot-instructions.md", - "gsd-core/templates/debug-subagent-prompt.md", "gsd-core/templates/dev-preferences.md", - "gsd-core/templates/discovery.md", "gsd-core/templates/discussion-log.md", "gsd-core/templates/milestone-archive.md", "gsd-core/templates/milestone.md", @@ -403,8 +395,10 @@ "gsd-core/templates/summary-complex.md", "gsd-core/templates/summary-minimal.md", "gsd-core/templates/summary-standard.md", + "gsd-core/templates/summary.compact.md", "gsd-core/templates/summary.md", "gsd-core/templates/user-profile.md", + "gsd-core/templates/user-setup.compact.md", "gsd-core/templates/user-setup.md", "gsd-core/templates/verification-report.md", "gsd-core/workflows/_runtime-launcher.snippet.sh", @@ -484,6 +478,7 @@ "gsd-core/workflows/help.md", "gsd-core/workflows/help/modes/brief.md", "gsd-core/workflows/help/modes/default.md", + "gsd-core/workflows/help/modes/full.compact.md", "gsd-core/workflows/help/modes/full.md", "gsd-core/workflows/help/modes/topic.md", "gsd-core/workflows/import.md", diff --git a/tests/helpers/compact-content-variant.cjs b/tests/helpers/compact-content-variant.cjs new file mode 100644 index 000000000..eb7f6fe87 --- /dev/null +++ b/tests/helpers/compact-content-variant.cjs @@ -0,0 +1,293 @@ +'use strict'; + +/** + * Shared library for the compact-content VARIANT guard (ADR-4139, epic #4139, + * Phase 6 #4406). See `gsd-core/references/compact-content-gate.md` §"Streams + * 1b and 4 — variant resolution" for the operational rule this module checks; + * this file is the mechanics, not the source of truth for behavior. + * + * This is a DIFFERENT shape from `compact-content-split.cjs` (Phase 3, stream + * 1's spine+detail partition). A partition is one document split into two + * halves that must never overlap (disjointness) and whose union must equal + * the original (completeness). A variant pair is two INDEPENDENT, complete + * documents that are EXPECTED to overlap heavily — the compact file is a + * hand-terser rewrite of the same content, not an extracted remainder. So + * this module has no disjointness check and no completeness-at-split-time + * check; it has the five checks `40-design.md` (Phase 6) describes instead: + * + * 1. Registration — `discoverRegisteredVariants` (a `.compact.md` + * file with no canonical sibling is not a registered pair; the guard + * test reports it as an orphan). + * 2. Reachability — `checkReachability` (a registered pair whose + * compact path is never named by any spine's "Read ... variant + * resolution" call site is unwired dead weight). + * 3. Protected content preserved — `checkProtectedContentPreserved` (a + * `` block's lines must appear verbatim in BOTH + * files, since nothing is "moved" in a variant pair — it is duplicated). + * 4. Size smaller — `checkSizeSmaller`. + * 5. Template consumer parity — NOT implemented here; it needs a real + * artifact-generation + real-parser round trip per template, which is + * the domain of `tests/compact-content-template-variant-parity.test.cjs` + * directly, not a generic file-shape check. + * + * This module only reads (filesystem + a search of markdown source for + * literal path substrings). No writes, no network, no git. + */ + +const fs = require('node:fs'); +const path = require('node:path'); + +const { extractProtectedBlocks, normalizeNonTrivialLines } = require('./compact-content-split.cjs'); + +/** Default scan roots: everywhere a `.compact.md` sibling can legally live. */ +const DEFAULT_VARIANT_ROOTS = [ + path.join(__dirname, '..', '..', 'gsd-core', 'workflows'), + path.join(__dirname, '..', '..', 'gsd-core', 'templates'), +]; + +/** Every markdown-source root a spine/fragment might name a variant path from. */ +const DEFAULT_SEARCH_ROOTS = [ + path.join(__dirname, '..', '..', 'gsd-core', 'workflows'), +]; + +const COMPACT_SUFFIX = '.compact.md'; + +/** + * Recursively list every file under `dir` whose name ends with `suffix`. + * Shared by both file-discovery needs this module has — `.compact.md` files + * (`findCompactFiles`) and general `.md` files to search for reachability + * (`findMarkdownFiles`) — which otherwise duplicated the same walk with only + * the extension predicate differing. + * @param {string} dir + * @param {string} suffix + * @returns {string[]} absolute paths + */ +function findFilesWithSuffix(dir, suffix) { + const results = []; + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return results; + } + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + results.push(...findFilesWithSuffix(full, suffix)); + } else if (entry.isFile() && entry.name.endsWith(suffix)) { + results.push(full); + } + } + return results; +} + +/** + * Recursively list every `*.compact.md` file under `dir`. + * @param {string} dir + * @returns {string[]} absolute paths + */ +function findCompactFiles(dir) { + return findFilesWithSuffix(dir, COMPACT_SUFFIX); +} + +/** + * Discover every registered compact/canonical variant pair under `roots`. + * + * A pair is registered by a `/.compact.md` file existing on disk — + * there is no separate registry. Its canonical sibling is `/.md` + * in the SAME directory. A `.compact.md` file with no canonical sibling is + * still returned (with `canonicalExists: false`) so the registration check + * can report it as an orphan by name, rather than silently skipping it. + * + * @param {string[]} roots + * @returns {{compactPath: string, canonicalPath: string, canonicalExists: boolean}[]} + */ +function discoverRegisteredVariants(roots = DEFAULT_VARIANT_ROOTS) { + const pairs = []; + for (const root of roots) { + for (const compactPath of findCompactFiles(root)) { + const dir = path.dirname(compactPath); + const stem = path.basename(compactPath, COMPACT_SUFFIX); + const canonicalPath = path.join(dir, `${stem}.md`); + pairs.push({ + compactPath, + canonicalPath, + canonicalExists: fs.existsSync(canonicalPath), + }); + } + } + return pairs.sort((a, b) => a.compactPath.localeCompare(b.compactPath)); +} + +/** + * Check 1 — registration. A `.compact.md` file must have a canonical sibling. + * @param {ReturnType} pairs + */ +function checkRegistration(pairs) { + const violations = []; + for (const pair of pairs) { + if (!pair.canonicalExists) { + violations.push({ kind: 'orphan_compact_file', compactPath: pair.compactPath }); + } + } + return violations; +} + +/** + * Check 2 — reachability. A registered pair's compact path must be named by + * at least one markdown file under `searchRoots` (a spine's "Read ... variant + * resolution" call site). Three needle forms, matched differently, because + * this corpus has two live conventions for naming these paths (found by + * walking up from the compact file itself to its nearest `gsd-core` ancestor, + * so this works the same way against the real repo and against a fixture + * that builds its own `/gsd-core/...` tree): + * + * - The `gsd-core/` form (e.g. `gsd-core/workflows/autonomous/steps/ + * converge-fail-fast.md`'s own convention) is unambiguous on its own — a + * different, longer path coincidentally ending in this exact multi-segment + * suffix is not a realistic false positive, so a plain substring match is + * sufficient without the "unprefixed" guard below. + * - The `` form without the leading `gsd-core/` (e.g. `workflows/help/ + * modes/full.compact.md`, `help.md`'s own dispatch-table convention) is + * equally unambiguous for the same reason. + * - The bare `.compact.md` form has no such guarantee — a same-named + * file under an unrelated nested directory (the exact class of bug already + * hit once this phase: `discuss-phase/templates/context.md` vs. the root + * `templates/context.md`) could grant it a false reachability. This form + * keeps the `isUnprefixedMatch` guard from `namesFragmentAsEntryPoint` + * (`scripts/lint-response-language-coverage.cjs`): a path character + * immediately before the match means this is the tail of some longer, + * different path, not the fragment itself. + * + * @param {ReturnType} pairs + * @param {string[]} searchRoots + */ +function checkReachability(pairs, searchRoots = DEFAULT_SEARCH_ROOTS) { + const violations = []; + const haystacks = []; + for (const root of searchRoots) { + for (const file of findMarkdownFiles(root)) { + haystacks.push(fs.readFileSync(file, 'utf8')); + } + } + for (const pair of pairs) { + if (!pair.canonicalExists) continue; // already reported by checkRegistration + const gsdCoreRelative = relativeToNearestGsdCore(pair.compactPath); + const stem = path.basename(pair.compactPath, COMPACT_SUFFIX); + const bareNeedle = `${stem}${COMPACT_SUFFIX}`; + const reached = haystacks.some((text) => { + if (gsdCoreRelative && (text.includes(`gsd-core/${gsdCoreRelative}`) || text.includes(gsdCoreRelative))) { + return true; + } + return isUnprefixedMatch(text, bareNeedle); + }); + if (!reached) { + violations.push({ kind: 'unreachable_compact_file', compactPath: pair.compactPath }); + } + } + return violations; +} + +/** + * Walk up from `filePath` to the nearest ancestor directory literally named + * `gsd-core`, and return the path from there to `filePath` (POSIX-separated). + * Returns `null` if no such ancestor exists. Anchoring on the literal + * `gsd-core` segment — rather than a hardcoded repo-root constant — is what + * lets this match both the real repo and a fixture built under its own + * `/gsd-core/...` tree the same way. + * @param {string} filePath + * @returns {string | null} + */ +function relativeToNearestGsdCore(filePath) { + const segments = filePath.split(path.sep); + const idx = segments.lastIndexOf('gsd-core'); + if (idx === -1) return null; + return segments.slice(idx + 1).join('/'); +} + +/** Is `needle` present in `text` with no path character immediately before it (any line)? */ +function isUnprefixedMatch(text, needle) { + return text.split(/\r?\n/).some((line) => { + const at = line.indexOf(needle); + if (at === -1) return false; + const before = at > 0 ? line[at - 1] : ''; + return !/[A-Za-z0-9_\-./]/.test(before); + }); +} + +function findMarkdownFiles(dir) { + return findFilesWithSuffix(dir, '.md'); +} + +/** + * Check 3 — protected content preserved. Every protected block's non-trivial + * lines in the canonical file must also appear (verbatim, after the same + * normalization the partition guard uses) somewhere in the compact sibling. + * Unlike the partition guard, this is NOT a sentinel-presence check on the + * compact file itself — the compact file need not carry `` + * markers of its own, since it is not itself audited for content it might + * shed later; it only must not have DROPPED the protected wording. + * + * @param {ReturnType} pairs + */ +function checkProtectedContentPreserved(pairs) { + const violations = []; + for (const pair of pairs) { + if (!pair.canonicalExists) continue; + const canonical = fs.readFileSync(pair.canonicalPath, 'utf8'); + const compact = fs.readFileSync(pair.compactPath, 'utf8'); + const { blocks } = extractProtectedBlocks(canonical); + if (blocks.length === 0) continue; + const compactLines = new Set(normalizeNonTrivialLines(compact)); + for (const block of blocks) { + const missing = block.lines + .map((l) => l.trim()) + .filter((l) => l.length > 0) + .filter((l) => !compactLines.has(l)); + if (missing.length > 0) { + violations.push({ + kind: 'protected_content_dropped', + canonicalPath: pair.canonicalPath, + compactPath: pair.compactPath, + missing, + }); + } + } + } + return violations; +} + +/** + * Check 4 — size smaller. The compact file must be strictly smaller than its + * canonical sibling; a same-size-or-larger "compact" file is not one. + * @param {ReturnType} pairs + */ +function checkSizeSmaller(pairs) { + const violations = []; + for (const pair of pairs) { + if (!pair.canonicalExists) continue; + const canonicalSize = fs.statSync(pair.canonicalPath).size; + const compactSize = fs.statSync(pair.compactPath).size; + if (!(compactSize < canonicalSize)) { + violations.push({ + kind: 'compact_not_smaller', + canonicalPath: pair.canonicalPath, + compactPath: pair.compactPath, + canonicalSize, + compactSize, + }); + } + } + return violations; +} + +module.exports = { + DEFAULT_VARIANT_ROOTS, + DEFAULT_SEARCH_ROOTS, + COMPACT_SUFFIX, + discoverRegisteredVariants, + checkRegistration, + checkReachability, + checkProtectedContentPreserved, + checkSizeSmaller, +};