* chore(npm): rebrand packages to @opengsd scope Rename: - get-shit-done-redux → @opengsd/get-shit-done-redux - @gsd-redux/sdk → @opengsd/gsd-sdk Add publishConfig.access=public for first-time scoped publish. CLI binary names (get-shit-done-redux, gsd-sdk, gsd-tools) unchanged. Sweeps install commands, npx invocations, CI publish/version-check workflows, tests, docs, READMEs (all translations), and the PACKAGE_NAME constant in check-latest-version. Bumps qs 6.15.1 → 6.15.2 to clear a moderate advisory surfaced by the audit-clean test (GHSA-q8mj-m7cp-5q26). Closes #126 * chore: pin 2.0.0 release + remove canary workflow - Bump both packages 1.50.0-canary.0 → 2.0.0 for first @opengsd publish - Remove .github/workflows/canary.yml and canary dist-tag handling in release.yml / release-sdk.yml - Drop canary section from VERSIONING.md Refs #126 * chore: address review findings + harden tarball-smoke timeout - .changeset/opengsd-org-rename.md: match project's custom parse.cjs frontmatter (type: Changed / pr: 127); the scoped @changesets/cli keys were silently rejected. - CONTEXT.md: drop two canary-stream policy lines and a dangling DEFECT.CANARY-VERSION-LEAK.cross-ref now that canary.yml is gone. - tests/release-tarball-smoke.install.test.cjs: pass timeout: 600_000 for npm pack + global install; the 3-minute runNpm default was timing out on slower Docker hosts (cartographer). Refs #126 * fix(sdk): add missing type/runtime devDependencies for build prepublishOnly invokes tsc which couldn't resolve @types/node, @types/ws, or synckit. They had been hoisted from root but were not declared in sdk/'s own package.json — first publish from a clean SDK tree failed. Refs #126 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(ci): use npm pack stdout instead of glob to find tarball `npm pack --silent` for a scoped package (@opengsd/get-shit-done-redux) produces `opengsd-get-shit-done-redux-*.tgz`, not `get-shit-done-redux-*.tgz`. Capture the filename from stdout instead of a hardcoded glob so the step works regardless of package name format. Fixes smoke (ubuntu-latest, 22, false) CI failure. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * ci: treat workflow-file changes as test-skip eligible `.github/workflows/install-smoke.yml` (and other workflow files) were in neither `test.yml` paths nor `test-skip.yml` paths-ignore, so neither workflow ran on a workflow-only commit — leaving the required test-skip check perpetually missing. Refs #126 * chore: reset version to 1.0.0 for first @opengsd publish Nothing has been published yet under the @opengsd scope, so the inaugural release uses 1.0.0 rather than 2.0.0. The "major bump" in the changeset reflects the breaking install-command change for users migrating from the prior unscoped `get-shit-done-redux`, not a numeric continuation from a 1.x line under the new identity. Refs #126 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
23 KiB
PRD: CJS↔SDK hard seam — Shared-Module migration
- Status: Reference
- Date: 2026-05-14
- Tracking issue: #3524
- Related ADR:
docs/adr/3524-cjs-sdk-hard-seam.md
Why this PRD exists
The ADR defines the target architecture — one source of truth per Shared Module, reusing the existing command-aliases.generated.* precedent. This PRD defines how to get there without breaking the running system. The migration is sequenced so the smallest, lowest-risk Shared Module ships first as a working proof of the pattern. Subsequent phases apply the same pattern to higher-stakes Modules. Each phase is independently shippable and independently reversible.
Problem statement
The CJS↔SDK boundary in open-gsd/get-shit-done-redux is structurally permeable. Multiple Shared Modules — STATE.md Document Module, Workstream Inventory Module, and several others — exist today as hand-synced pairs of .cjs and .ts files with character-identical implementations. Constants (CONFIG_DEFAULTS, VALID_CONFIG_KEYS) are likewise defined twice. The boundary is policed only by:
- A naming-parity test (
tests/config-schema-sdk-parity.test.cjs) - Output-parity golden tests for read-only handlers (
sdk/src/golden/read-only-parity.integration.test.ts)
These catch some drift but miss:
- Structure drift under defaults (#3523: top-level
branching_strategyreturned as'none'by CJS,'phase'by SDK) - Warning/error-message drift (#3523: CJS warns falsely; SDK silently grafts)
- Mutation-path drift (each side tested separately; no cross-side mutation fixture)
- New-Module drift (a new constant added to one side and not the other is invisible)
Each of #1535, #1542, #2047/#2052, #2638/#2655, #2653/#2670, #2687/#2706, #2798/#2816, #3055/#3116, #3523 fits this shape.
The fix is mechanical: for every hand-synced pair, replace one side with a generated artifact derived from the other side as the source of truth, modeled on the existing sdk/scripts/gen-command-aliases.ts + sdk/scripts/check-command-aliases-fresh.mjs pattern.
Goals
- Eliminate the drift bug class. Concretely: zero new bugs with the
drift-recurrenceretroactive label in the four months following the seam landing. - One source of truth per Shared Module, enforced by per-Module freshness checks at PR time.
- Hand-synced pairs of
.cjs/.tsfiles become impossible to merge (lint gate). - No new build tooling. The existing generator pattern scales.
Non-goals
- Removing the CJS CLI.
gsd-toolscontinues to exist for shell-script back-compat. (Its dispatcher delegates to the SDK runtime bridge after Phase 5; the external CLI contract is unchanged.) - Migrating CJS-only Modules (graphify, gsd2-import, schema-detect, fallow-runner, intel, drift) to SDK handlers.
- Defining a Verify Module before the verify surface has a shared Interface. Verify-surface deepening is precondition work for a future enhancement.
Approach
The repo already has a working precedent for shared CJS/SDK Modules: sdk/scripts/gen-command-aliases.ts emits both sdk/src/query/command-aliases.generated.ts and get-shit-done/bin/lib/command-aliases.generated.cjs from a single TypeScript source. sdk/scripts/check-command-aliases-fresh.mjs is the CI freshness gate that fails when either generated file drifts from the source. This PRD generalizes that pattern to every Shared Module.
For each Shared Module being migrated:
- Promote one side to the source of truth (the TS source, because it already carries types).
- Write
sdk/scripts/gen-<module>.tsthat emits both.generated.tsand.generated.cjs. - Write
sdk/scripts/check-<module>-fresh.mjsmodeled oncheck-command-aliases-fresh.mjs. - Replace the hand-authored CJS file with a thin re-export from the generated file.
- Wire the freshness check into CI.
- Once green for one release cycle, delete the now-unreferenced hand-authored content from history's view by removing dead re-exports.
A separate, standing CI lint (scripts/lint-shared-module-handsync.cjs, introduced in Phase 6) blocks any new hand-synced pair from being merged.
Phased plan
Phases are sized to ship in one to two PRs each. Each phase has its own GitHub issue, linked back to #3524, opened only after the previous phase ships.
Phase 1 — STATE.md Document Module (smallest possible proof)
Why first. bin/lib/state-document.cjs and sdk/src/query/state-document.ts are already a character-identical hand-synced pair of pure transforms (the file headers explicitly say "Pure transforms for STATE.md text. This module does not read the filesystem and does not own persistence or locking."). Deletion test passes on contact: one side can be deleted as soon as the other becomes the generated artifact. This is the safest possible first step and the canonical proof that the generator pattern works for executable logic, not just alias tables.
Scope:
- Promote
sdk/src/query/state-document.tsto a source undersdk/src/state-document/index.ts(or keep in place — decided in implementation). - Write
sdk/scripts/gen-state-document.tsthat emitsget-shit-done/bin/lib/state-document.generated.cjs(and optionally re-exports the TS form at its existing location). - Write
sdk/scripts/check-state-document-fresh.mjsmodeled oncheck-command-aliases-fresh.mjs. - Replace
bin/lib/state-document.cjscontent with a thin re-export fromstate-document.generated.cjs. Keep the existing filename so callers (e.g.workstream-inventory.cjs:16) don't need to update imports. - Wire
check-state-document-fresh.mjsinto CI alongsidecheck-command-aliases-fresh.mjs.
Acceptance criteria:
bin/lib/state-document.cjscontains only a re-export fromstate-document.generated.cjs.sdk/scripts/check-state-document-fresh.mjspasses in CI and fails when intentionally desynchronized.- All existing call sites (CJS:
state.cjs,workstream-inventory.cjs; SDK:state-mutation.ts,state-project-load.ts, others importingstate-document) work unchanged. - Existing STATE.md unit tests on both sides pass.
- CONTEXT.md "STATE.md Document Module" entry is amended (one sentence) to note the source-of-truth file path.
Rollback: Revert the branch. Re-importing the deleted CJS file content from git history restores the prior hand-synced shape. No external consumer is broken.
Phase 2 — Configuration Module (closes the #3523 class)
Why second. This is the Module that triggered the work. It is the highest-leverage drift surface and the test of whether the pattern scales from a pure-transform Module to a Module that consumes data manifests.
Scope:
- Add a Configuration Module entry to
CONTEXT.mdfirst. Definition: "Module owning config load, legacy-key normalization, defaults merge, and explicit on-disk migration for.planning/config.json." Interface and invariants per ADR §6. - Extract
CONFIG_DEFAULTS,VALID_CONFIG_KEYS,DYNAMIC_KEY_PATTERNS,RUNTIME_STATE_KEYSto two data manifests:sdk/shared/config-schema.manifest.jsonandsdk/shared/config-defaults.manifest.json. Precedent:sdk/shared/model-catalog.json. - Write the Configuration Module source at
sdk/src/configuration/index.ts. Implementation imports the two manifests and exportsloadConfig,normalizeLegacyKeys,mergeDefaults,migrateOnDisk. - Write
sdk/scripts/gen-configuration.tsto emitget-shit-done/bin/lib/configuration.generated.cjsand (if needed)sdk/src/query/config-schema.generated.ts. - Write
sdk/scripts/check-configuration-fresh.mjs. - Replace the inline implementations in
bin/lib/core.cjs:loadConfig(lines 220–243, 434–449, 485) andbin/lib/config.cjs(the validation surface) with thin Adapters over the generated Module. Delete the inlineCONFIG_DEFAULTS, the false-positive warning atcore.cjs:444-449, and the duplicated_deepMergeConfig. - Replace
sdk/src/config.ts:mergeDefaults(lines 192–218) with a re-export from the new Module. - Extend
sdk/src/golden/read-only-parity.integration.test.tswith a fixture matrix for the four legacy-key normalizations: top-levelbranching_strategy, top-levelsub_repos,multiRepo: true, top-leveldepth.
Acceptance criteria:
CONTEXT.mdcontains a Configuration Module entry with the Interface contract.bin/lib/core.cjsandbin/lib/config.cjscontain no localCONFIG_DEFAULTSorVALID_CONFIG_KEYSliterals; both load from the manifests via the generated Module.- Bug #3523 fixture matrix passes on both CJS and SDK paths; the false-positive warning at the old
core.cjs:444-449site is gone. - Golden parity matrix green for all four legacy-key shapes.
- Bug #3523 closed with a back-reference to this phase.
Rollback: Revert the branch; inline implementations restore from git history. The manifest files remain unreferenced.
Phase 3 — Workstream Inventory Builder + remaining hand-synced pairs
Why third. Phase 1 proves the pattern for pure transforms. Phase 2 proves it for data-manifest-backed logic. Phase 3 generalizes across the remaining hand-synced pairs surfaced by the audit. The Workstream Inventory Module is the headline because it requires the Builder/Reader split — the projection logic is pure and shareable, but the directory traversal is legitimately sync (CJS) vs async (SDK). This is the pattern for every paired Module with mixed pure-and-I/O concerns.
Scope:
- Write the Workstream Inventory Builder source at
sdk/src/workstream-inventory/builder.ts. Pure function: takes a list of directory entries plus per-workstream STATE.md text plus plan-scan results and returns the typedWorkstreamPhaseInventory/WorkstreamInventoryprojection. No fs reads. - Write
sdk/scripts/gen-workstream-inventory-builder.tsto emitget-shit-done/bin/lib/workstream-inventory-builder.generated.cjsandsdk/src/query/workstream-inventory-builder.generated.ts. - Write
sdk/scripts/check-workstream-inventory-builder-fresh.mjs. - Refactor
bin/lib/workstream-inventory.cjsto a sync Reader Adapter: doesfs.readdirSync+readFileSyncof STATE.md, calls the Builder. The projection logic is removed. - Refactor
sdk/src/query/workstream-inventory.tsto an async Reader Adapter: same shape, async I/O, calls the Builder. - Amend the
CONTEXT.md"Workstream Inventory Module" entry with a sub-paragraph documenting the Builder/Reader split. - Audit remaining likely pairs (
frontmatter.cjs↔frontmatter-mutation.ts,plan-scan.cjs↔plan-scanSDK equivalents) for pure-transform sharability. For each confirmed-shareable pair, apply the same Builder pattern in this phase. For pairs whose duplication is structural (e.g. routing tables, sync vs async with different return shapes), document the decision in the phase issue and defer.
Acceptance criteria:
bin/lib/workstream-inventory.cjsandsdk/src/query/workstream-inventory.tsno longer share projection logic; both call the generated Builder.- CONTEXT.md "Workstream Inventory Module" entry reflects the split.
- Workstream-related golden tests pass on both sides.
- Every additional Module in scope has its own freshness check.
- Each Module not migrated in this phase has a one-paragraph deferral note (in the phase issue, not in the ADR).
Phase 4 — Project-Root Resolution Module
Scope:
- Add a Project-Root Resolution Module entry to
CONTEXT.md. Interface:findProjectRoot(startDir),findEffectiveRoot(startDir, options). - Source at
sdk/src/project-root/index.ts. Pure function: takes a path and an injected fs probe (or just usesnode:fssince both runtimes have it synchronously). - Generator at
sdk/scripts/gen-project-root.ts. - Freshness check at
sdk/scripts/check-project-root-fresh.mjs. - Replace
bin/lib/core.cjs:74-140with a thin Adapter over the generated Module. - Replace
sdk/src/helpers.ts:497-630with a thin Adapter over the same Module. - Extend parity tests for: standalone project, monorepo with
planning.sub_repos, legacymultiRepo: true, deep nesting.
Acceptance criteria:
findProjectRootis defined exactly once in source form.- Both sides import the generated Module.
- Parity tests pass for the four configurations above.
Phase 5 — CJS Command Router Adapter: delegate to the SDK runtime bridge
Why fifth. Phases 1–4 collapse drift in shared logic. Phase 5 collapses drift in parallel logic — the per-side state/verify/init/phase/roadmap/validate handler implementations on the CJS side. After Phase 5, every canonical command running via gsd-tools executes the same SDK handler that gsd-sdk query executes, in-process, with no subprocess hop. The seam becomes a real wall.
Scope:
- Amend the existing
CJS Command Router Adapter ModuleCONTEXT.md entry to document runtime-bridge delegation. - Expose a synchronous-friendly entry on
QueryRuntimeBridgefor CJS callers. TodayQueryRuntimeBridge.execute()is async; the bridge gains aexecuteForCjs(input) → { exitCode, stdoutChunks, stderrLines }synchronous wrapper that runs the dispatch underdeasyncor a controlledrunUntilsemantic. (Toolchain choice resolved in the Phase 5 issue; if synchronous bridging is not viable, fall back toAtomics.waiton a worker channel — nevergsd-sdksubprocess.) - Replace each canonical-family
handlersmap inbin/lib/*-command-router.cjswith a generated delegate emitter that, per subcommand, callsexecuteForCjs({ canonical, argv, env, cwd })and writes the result through the existing CJS output Adapter. - For each canonical command family in order —
state.*,verify.*,phase.*,phases.*,validate.*,roadmap.*,init.*,frontmatter.*,config.*, plus the non-family commands listed insdk/src/query/command-manifest.non-family.ts— migrate one family per sub-PR. Run the golden parity matrix per family before merging. - Delete CJS-side handler files (or shrink to delegates) for each migrated family:
state.cjs,verify.cjs,init.cjs,phase.cjs,phases.cjs,validate.cjs,roadmap.cjs,milestone.cjs,frontmatter.cjs,config.cjswrite paths, plan-scan handlers, etc. The pure-transform Shared Modules from Phases 1–4 remain untouched; only the per-family handler entry points are replaced. - CJS-only Module handlers (
graphify,gsd2-import,schema-detect,fallow-runner,intel,drift,installer-migrations) keep their in-process CJS implementations. They are not in the canonical family registry and do not route through the SDK runtime bridge. - Extend
sdk/src/golden/golden.integration.test.tsto verify identical exit code + stdout chunks + stderr lines betweengsd-tools <family> <subcommand>(now delegated) andgsd-sdk query <canonical>for every canonical command in the manifest.
Acceptance criteria:
- CONTEXT.md "CJS Command Router Adapter Module" entry documents runtime-bridge delegation.
QueryRuntimeBridge.executeForCjs(or equivalent) ships with the synchronous semantics resolved in the phase issue.- Every canonical command family in
command-manifest.*.tsroutes viaexecuteForCjs. CJS-only commands continue to route via the existing CJS handler. - Each per-family CJS handler file (
state.cjs,verify.cjs, …) contains no command-specific logic — only the delegate wiring or has been deleted entirely. - Golden parity matrix verifies output equivalence across
gsd-toolsandgsd-sdkfor every canonical command. No regressions in workflow markdown that callsgsd-tools. - Subprocess overhead per
gsd-toolsinvocation does not increase (the bridge is in-process, not agsd-sdksubprocess).
Rollback (per family): Each family's PR is independently revertible. The CJS handler files for an un-migrated family remain on disk in git history; if a family's delegation regresses, revert that family's PR and the CJS-side handler is restored.
Out-of-scope under Phase 5: The CJS-only Modules (graphify, gsd2-import, etc.) and workflow markdown that calls them — those calls continue to hit the in-process CJS handler, no change. Migrating CJS-only Modules to SDK is a separate enhancement.
Phase 6 — Enforcement hardening + retrospective
Scope:
- Write
scripts/lint-shared-module-handsync.cjs. Greps for any pair of files atget-shit-done/bin/lib/<name>.cjsandsdk/src/query/<name>.ts(orsdk/src/<name>.ts) where neither file matches*.generated.*and the pair is not on an explicit allow-list. Allow-list documents the cooperating-sibling exceptions (e.g. routing files where the implementations are structurally different). - Verify each Shared Module from Phases 1–4 has its own freshness check wired to CI.
- Verify Phase 5's golden parity matrix covers every canonical command family.
- Add CODEOWNERS rules for
sdk/src/<module>/**for each Shared Module source-of-truth directory, forsdk/shared/*.manifest.json, and forsdk/src/query-runtime-bridge.ts(the Phase 5 boundary). Architecture-team review required. - Retrospectively walk the recurring-bug list (#1535 ... #3523). For each, document in
docs/agents/cjs-sdk-seam.mdwhich enforcement layer (handsync lint, freshness check, manifest data isolation, per-Module drift lint, runtime-bridge delegation) would have blocked it. - Write
docs/agents/cjs-sdk-seam.mdas a CONTRIBUTING-linked guide for adding a new Shared Module and for adding a new canonical command.
Acceptance criteria:
lint-shared-module-handsync.cjsruns in CI; demonstrated to block an intentional regression PR.- Every Shared Module from Phases 1–4 appears in a freshness-check workflow step.
- Phase 5's golden parity matrix is in CI on every PR that touches
bin/lib/*orsdk/src/query/*. - CODEOWNERS rules in place.
- Retrospective document committed.
- No PR can land that re-introduces the #3523 anti-pattern or that bypasses the runtime-bridge delegation for a canonical command.
Cross-phase concerns
Backwards compatibility
The CJS public CLI surface (gsd-tools <subcommand>) does not change. Flags, exit codes, stdout shapes preserved. Every phase replaces internal implementations behind the existing Module Interfaces; the external contracts are pinned by the existing golden parity suite plus the new fixture matrices.
Performance
No subprocess overhead anywhere. The generated .cjs files are require-able CommonJS modules; the SDK consumes the TS source directly. Module load cost adds ≤ 10 ms per require across all phases combined.
Phase 5 specifically preserves the in-process model: QueryRuntimeBridge.executeForCjs runs the SDK handler in the same Node process as the CJS dispatcher. No gsd-sdk subprocess is invoked. Synchronous bridging adds at most a handful of microseconds per call vs the previous direct CJS handler invocation, dominated by the existing dispatch policy overhead.
Build/install pipeline impact
- Each generator runs at build time on the developer machine (and in CI for the freshness check). No runtime generator execution.
- The published
@opengsd/get-shit-done-reduxpackage already includes bothget-shit-done/bin/andsdk/dist/. The generated.cjsfiles are committed to the repo (likecommand-aliases.generated.cjstoday), so the install flow is unchanged — no on-install code generation. npm run build:sdkcontinues to do what it does. Generators are invoked vianpm run gen:<module>per the existing precedent.
Risks
| Risk | Likelihood | Mitigation |
|---|---|---|
| Generator output drifts from source between commits | Medium | check-<module>-fresh.mjs per Module catches this at PR time. Precedent already in use for command-aliases. |
| A Shared Module's TS source uses features not expressible in CommonJS output | Low | Generator emits a CJS-compatible subset (no ESM-only syntax in source). Existing gen-command-aliases.ts template covers this. |
Phase 2's removal of inline _deepMergeConfig changes a subtle merge semantic |
Medium | Golden parity matrix is the test. If _deepMergeConfig and the new Module disagree on a fixture, the matrix fails and the new Module is amended before merge. |
migrateOnDisk rollout silently changes user-visible behavior on upgrade |
Medium | migrateOnDisk is explicit and opt-in; installer calls it once on next upgrade, with a release-note entry. Standalone command gsd-tools migrate-config for manual invocation. |
| CODEOWNERS rule slows down architecture-team responsiveness | Medium | Apply CODEOWNERS only to source-of-truth directories and manifests. Adapters and .generated.* files remain open. Architecture team commits to a ≤ 24 h SLA. |
| Phase 3's audit surfaces more pairs than expected, scope creeps | Medium | Each non-Phase-1/2 Module is scope-checked in its phase issue. Pairs that don't fit cleanly are deferred with a documented reason. |
Phase 5's synchronous-bridging mechanism (executeForCjs) has no clean shape — deasync is C++-bound, Atomics.wait requires a Worker, refactoring every SDK handler to be sync is huge |
High | Phase 5 spike resolves this before any family migration. If no clean mechanism exists, Phase 5 is descoped to the families whose SDK handlers are already synchronous, and the remainder shift to a follow-up enhancement. |
| Phase 5 family migrations regress observable CJS output (exit codes, stdout/stderr shape) | Medium | Golden parity matrix per family is the gate. A family's PR cannot merge until the matrix is green across every canonical command in that family. |
| Phase 5 changes startup time because the SDK runtime bridge eagerly loads more handlers than the previous CJS routers | Low | Lazy-load handlers behind the bridge (already the SDK's model). Measure time gsd-tools state load before/after migration; fail the family PR if median latency regresses >20 ms. |
Open questions (resolved before the phase that depends on them)
- Phase 1 source location —
sdk/src/state-document/index.ts(move) vssdk/src/query/state-document.ts(in place). Decided when Phase 1 PR is drafted. - Phase 2 manifest format — JSON vs JSONC vs TypeScript-as-source. Decided in Phase 2. JSON wins unless we need comments for invariants documentation.
- Phase 3 sibling-Module audit — exact list of pairs that get Builder-split vs deferred. Decided as a deliverable of Phase 3's spike.
- Phase 5 synchronous-bridging mechanism —
executeForCjsimplementation strategy:deasyncnative module (battle-tested but C++ binding),Atomics.waiton a worker channel (zero-binding but spins a Worker), or refactor every async SDK handler to expose a sync entry point (cleanest but largest scope). Decided in the Phase 5 spike issue before any family migration begins. - Phase 5 family migration order — which canonical family migrates first. Recommended order: smallest read-only family first (likely
frontmatter.*orconfig.* read paths) as the proof of pattern, then state/verify/phase/roadmap/validate/init in increasing complexity. Decided in the Phase 5 issue. - Phase 6 retrospective format — table vs prose. Decided when the retrospective document is drafted.
Done when
#3524 is closed when all six phases have shipped, each with its own merged PR closing its own phase issue, and the Phase 6 retrospective confirms every historical drift bug from the recurring list would have been blocked by one of the five enforcement layers (handsync lint, freshness check, manifest data isolation, per-Module drift lint, runtime-bridge delegation).