T-9JRW-workspace-membership-drift-assertion
Status: open/ready · Impact: medium · Complexity: small
AUTO-DEFINED: this spec was best-effort machine-authored by /sdlc:task-auto-define on 2026-07-20 because the task is autonomy: autonomous/pr. Review the Goal, Approach, Today, Files-to-touch, and Acceptance-criteria carefully before trusting it.
A directory move that rewrites a workspaces glob can silently change workspace membership, and nothing catches it mechanically. This task adds a membership-drift assertion — resolved workspace/project membership compared before and after a move, failing unless the task explicitly declares a delta — so a mis-scoped glob is caught by the gate instead of by a human noticing the lockfile grew. Originating from T-C9RD-consolidate-augmented-into-solutions on sksizer/dev.
T-C9RD’s prescribed
solutions/*/apps/*workspaces glob was wrong as written: it silently absorbed augmented-books and augmented_dot_com, which are their own Bun roots per D-0016/D-0018 and were never root workspace members, pulling in 60 extra packages. Two!negations had to be added by hand after a human noticed the lockfile had grown. A membership-drift assertion — workspace member COUNT (and id set) unchanged across a move unless the task explicitly declares a delta — would have caught this mechanically. Add such a check, comparing the resolved workspace/project membership before and after (moon query projectsgave a crisp before/after count in T-J5DI, 101 to 100). Implement alongside the gate ops in apps/sdlc/lib/services/gate/ops/ (see worktree-scope.ts for the op shape). Distinct from T-VY9S-workspaces-glob-covers-apps-consumers, which is about widening the glob for apps/* consumers, not about detecting drift.
| Location | Role today |
|---|---|
package.json | Root Bun workspace manifest. Its workspaces array of globs IS the membership definition a directory move rewrites; nothing asserts the resolved member set is stable across such a rewrite. |
.moon/workspace.yml | moon’s separate project discovery (projects.globs + projects.sources). The before/after counts cited in T-J5DI-remove-clear-duplicate-augmented-packages came from moon query projects against this file, run by hand. |
apps/sdlc/lib/services/gate/ops/worktree-scope.ts | The only pre-commit gate op under gate/ops/. Supplies the op shape this task copies: defineOp with hidden: true, zod input/output, a cli.render hook returning non-zero, and a WORKTREE_SCOPE_GUARD=skip env bypass. |
apps/sdlc/lib/services/gate/ops/worktree-scope.ts#findCollisions | The pure comparison core the handler calls — the handler/core split this task mirrors. |
apps/sdlc/lib/services/quality/baseline.ts | Precedent for a before/after comparison core living at the service-package level with thin ops (quality baseline capture / quality baseline diff) layered on top. |
apps/sdlc/lib/_generated/op-manifest.ts | Generated static barrel of op modules. A new op module is invisible to bun build --compile until this is regenerated. |
apps/sdlc/scripts/gen-op-manifest.ts | The generator that rewrites that barrel. |
lefthook.yml | pre-commit driver. Wires sdlc gate worktree-scope plus the project-check gates; no workspace-membership gate exists. Its project-check-docs-drift comment records why a gate reads the git INDEX rather than the working tree under parallel sessions. |
apps/sdlc/lib/services/gate/tests/worktree_scope.test.ts | Test precedent: drives a gate op through the real CLI against an ephemeral git repo built in a temp dir. |
apps/sdlc/conventions/worktree-scope-guard.md | Contract doc for the sibling guard — carve-outs, env override — that the new guard’s doc mirrors. |
Proposed
Section titled “Proposed”A sdlc gate workspace-membership op that resolves the ROOT Bun workspace member
set twice — once from the package.json and file tree at a baseline git rev
(--base, default HEAD), once from the same pair read out of the git index —
and fails when the two sets differ. A deliberate move declares its delta through
WORKSPACE_MEMBERSHIP_DELTA="+<dir>,-<dir>" and passes only when the observed
delta equals the declaration exactly, so the T-C9RD shape (declare one move, 60
packages tag along) still fails. WORKSPACE_MEMBERSHIP_GUARD=skip is the blunt
bypass, mirroring WORKTREE_SCOPE_GUARD=skip. Wired into lefthook.yml’s
pre-commit block beside the worktree-scope guard, so a mis-scoped glob is
rejected at the commit boundary instead of surfacing later as unexplained
bun.lock growth.
Approach
Section titled “Approach”- Add the pure core at
apps/sdlc/lib/services/gate/workspace-membership.ts. It performs no ambient I/O — callers inject the manifest text and the path listing — and exports:parseWorkspaceGlobs(packageJsonText): string[]— reads theworkspaceskey, accepting both the array form and the{ packages: [...] }object form, and returns the globs verbatim including leading!negations.expandMembers(globs, paths): Member[]— a path qualifies when it is<dir>/package.jsonand<dir>matches at least one positive glob and no!negation, matched withtinyglobby(already a dependency of@sksizer/sdlc).Member = { dir: string; name: string | null }, sorted bydir.diffMembership(before, after): { added: Member[]; removed: Member[] }— set difference keyed ondir.parseDeclaredDelta(spec): { added: string[]; removed: string[] }— parses the comma-separated+dir/-dirdeclaration form.
- Resolve the BEFORE side from the baseline rev:
git show <base>:package.jsonfor the manifest andgit ls-tree -r --name-only <base>for the path listing. - Resolve the AFTER side from the git INDEX:
git show :package.jsonandgit ls-files --cached. The index — not the working tree — is what the commit will contain, and matches the reasoning already recorded onproject-check-docs-driftinlefthook.yml(a working-tree read is contaminated by concurrent sessions’ uncommitted edits). Both sides issue their git calls throughctx.git, theCommandRunnerseam the worktree-scope handler already uses; no rawchild_processspawn. - Add the op at
apps/sdlc/lib/services/gate/ops/workspace-membership.tsas a thindefineOp({ path: ["gate", "workspace-membership"], hidden: true, ... })wrapper over the core, mirroring the worktree-scope descriptor.- Input:
{ projectRoot: string; base: string }withbasedefaulting toHEAD. - Output:
{ base, before: { count, members }, after: { count, members }, added, removed, declared, drift, skipped, reason? }. - Handler order: env bypass first, then membership resolution, then declared-delta reconciliation.
- Input:
- Implement the two escape hatches in the handler:
WORKSPACE_MEMBERSHIP_GUARD=skipreturns{ skipped: true, reason }without measuring.WORKSPACE_MEMBERSHIP_DELTAsetsdeclared;driftis false only when the observedadded/removeddir sets equal the declared sets exactly.- Absent a declaration, any non-empty delta sets
drift: true.
- Add the
renderWorkspaceMembership(out, io)hook: return 0 whenskippedis true ordriftis false; otherwise write the before/after counts and one+ <dir>/- <dir>line per changed member to stderr, name both env vars, and return 1 so lefthook shapes the exit red. - Register the op by running
apps/sdlc/scripts/gen-op-manifest.tsunderbun run, which adds theimport "@lib/services/gate/ops/workspace-membership.ts";line toapps/sdlc/lib/_generated/op-manifest.ts. - Wire the gate into
lefthook.ymlas aworkspace-membership-guardentry inpre-commit.commands, placed next toworktree-scope-guard, runningbun apps/sdlc/cli/sdlc.ts gate workspace-membership, with a comment naming both env vars and pointing at the conventions doc. - Write the conventions doc at
apps/sdlc/conventions/workspace-membership-guard.md, mirroringworktree-scope-guard.md: what the gate measures, the--baseflag, the declared-delta format, and the two env vars. - Add
apps/sdlc/lib/services/gate/tests/workspace_membership.test.ts, modelled onworktree_scope.test.ts. Build an ephemeral git repo whose committedpackage.jsonhasworkspaces: ["packages/*"]and two member dirs; then in the index rewrite the glob to["packages/*", "apps/*"]and addapps/extra/package.json. Drivebun run <cli> gate workspace-membership --advancedand assert the five cases enumerated in AC-2 through AC-5.
Files to touch
Section titled “Files to touch”| Location | Kind | Change |
|---|---|---|
apps/sdlc/lib/services/gate/workspace-membership.ts | new | Pure core — parseWorkspaceGlobs, expandMembers, diffMembership, parseDeclaredDelta; no ambient I/O. |
apps/sdlc/lib/services/gate/ops/workspace-membership.ts | new | Thin hidden defineOp wrapper: reads base-rev and index state through ctx.git, calls the core, renders the drift report on stderr, returns non-zero on drift. |
apps/sdlc/lib/services/gate/tests/workspace_membership.test.ts | new | Ephemeral-git-repo suite driving the op through the real CLI; covers clean, drifted, declared-matching, declared-mismatched, and skipped. |
apps/sdlc/conventions/workspace-membership-guard.md | new | Guard contract doc mirroring worktree-scope-guard.md. |
apps/sdlc/lib/_generated/op-manifest.ts | modify | Regenerated to add the new op’s import line so it lands in the compiled binary. |
lefthook.yml | modify | New workspace-membership-guard command in pre-commit.commands beside worktree-scope-guard. |
Acceptance criteria
Section titled “Acceptance criteria”- AC-1:
bun apps/sdlc/cli/sdlc.ts gate workspace-membership --advanced --helpexits 0 and prints the op’s summary, confirming it is registered as a hidden gate op alongsidegate worktree-scope. - AC-2: On a repo whose index membership matches the base rev, the op exits 0 and
--output jsonreportsdrift: falsewith emptyaddedandremovedarrays. - AC-3: When the index widens the root
workspacesglob so it absorbs a directory that was not a member at the base rev, the op exits 1 and its stderr names that directory. - AC-4: With
WORKSPACE_MEMBERSHIP_DELTAset to exactly the observed delta the op exits 0; with a declaration that omits one observed added member it still exits 1. - AC-5: With
WORKSPACE_MEMBERSHIP_GUARD=skipin the environment the op exits 0 and--output jsonreportsskipped: true, regardless of the observed delta. - AC-6:
bun test apps/sdlc/lib/services/gate/tests/workspace_membership.test.tspasses, exercising the five cases in AC-2 through AC-5. - AC-7:
apps/sdlc/lib/_generated/op-manifest.tscontains the lineimport "@lib/services/gate/ops/workspace-membership.ts";, and re-running the generatorapps/sdlc/scripts/gen-op-manifest.tsleaves that file byte-identical. - AC-8:
lefthook.ymlcontains aworkspace-membership-guardentry underpre-commit.commandsinvokingbun apps/sdlc/cli/sdlc.ts gate workspace-membership. - AC-9: The command-seam linter
apps/sdlc/scripts/check_command_seam.shexits 0 — the two new modules reach git only throughctx.git/ the command seam.
Out of scope
Section titled “Out of scope”- moon project membership. The moon project set is defined separately in
.moon/workspace.ymland resolving it requires themoonbinary on PATH, which a pre-commit gate should not depend on. The T-C9RD regression was a rootworkspacesglob fault, so the Bun member set is what this gate measures;moon query projectsstays the human cross-check. - Nested Bun workspace roots (
apps/sdlc/desktop, augmented-books, augmented_dot_com perD-0016/D-0018). Determined is no longer one: its subtrees are enrolled per-solution in the rootworkspaces(solutions/determined/{apps,experiences,products,packages}/*), so they ARE root members this gate measures. This gate measures the ROOT workspace’s membership only. - Widening the root
workspacesglob to coverapps/*consumers — that is T-VY9S-workspaces-glob-covers-apps-consumers’s scope. This task detects drift; it does not change the glob. - Lockfile-size or dependency-count assertions on
bun.lock. Lockfile growth was the symptom a human noticed; membership is the cause this gate measures. - Working-tree (unstaged) measurement. The gate reads the git index, per the
parallel-session contamination reasoning recorded on
project-check-docs-driftinlefthook.yml. - Auto-repairing a drifted glob — e.g. synthesizing the
!negations T-C9RD needed by hand. The gate reports; the human fixes.
Dependencies
Section titled “Dependencies”- none
Discovery context
Section titled “Discovery context”Spawned by /sdlc:spawn-task-pr on 2026-07-20 UTC from T-C9RD-consolidate-augmented-into-solutions in https://github.com/sksizer/dev.