Merge pull request #3110 from patrickclery/fix/3100-search-dirs-colon-leaks

fix: replace stale /gsd: references in agents/, sdk/src/, and .clinerules
This commit is contained in:
Tom Boucher
2026-05-06 20:52:43 -04:00
committed by GitHub
9 changed files with 75 additions and 22 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 3110
---
**Stale `/gsd:<cmd>` references no longer leak into model context on non-Gemini runtimes** — `scripts/fix-slash-commands.cjs` SEARCH_DIRS did not cover `agents/`, `sdk/src/`, or top-level files, so 9 colon-form references survived in 6 files. The hit at `agents/gsd-codebase-mapper.md:105` propagated into `~/.claude/agents/` at install time (the fixer is not wired into install) and produced unrunnable `/gsd:<cmd>` suggestions in agent output on Claude Code, Cursor, Windsurf, etc. Closes #3100.

View File

@@ -5,9 +5,9 @@ GSD is a structured AI development workflow system. It coordinates AI agents thr
## Core Rule: Never Edit Outside a GSD Workflow
Do not make direct repo edits. All changes must go through a GSD workflow:
- `/gsd:plan-phase` → plan the work
- `/gsd:execute-phase` → build it
- `/gsd:verify-work` → verify results
- `/gsd-plan-phase` → plan the work
- `/gsd-execute-phase` → build it
- `/gsd-verify-work` → verify results
## Architecture
- `get-shit-done/bin/lib/` — Core Node.js library (CommonJS .cjs, no external deps)

View File

@@ -102,7 +102,7 @@ The prompt may include a line of the form:
--paths <p1>,<p2>,...
```
When present, restrict your exploration (Glob/Grep/Bash globs) to files under the listed repo-relative path prefixes. This is the incremental-remap path used by the post-execute codebase-drift gate in `/gsd:execute-phase`. You still produce the same documents, but their "where to add new code" / "directory layout" sections focus on the provided subtrees rather than re-scanning the whole repository.
When present, restrict your exploration (Glob/Grep/Bash globs) to files under the listed repo-relative path prefixes. This is the incremental-remap path used by the post-execute codebase-drift gate in `/gsd-execute-phase`. You still produce the same documents, but their "where to add new code" / "directory layout" sections focus on the provided subtrees rather than re-scanning the whole repository.
**Path validation:** Reject any `--paths` value containing `..`, starting with `/`, or containing shell metacharacters (`;`, `` ` ``, `$`, `&`, `|`, `<`, `>`). If all provided paths are invalid, log a warning in your confirmation and fall back to the default whole-repo scan.

View File

@@ -18,8 +18,22 @@ const SEARCH_DIRS = [
path.join(__dirname, '..', 'get-shit-done', 'templates'),
path.join(__dirname, '..', 'get-shit-done', 'contexts'),
path.join(__dirname, '..', 'commands', 'gsd'),
path.join(__dirname, '..', 'agents'),
path.join(__dirname, '..', 'sdk', 'src'),
];
const EXTENSIONS = new Set(['.md', '.cjs', '.js']);
const TOP_LEVEL_FILES = [
path.join(__dirname, '..', '.clinerules'),
];
const SKIP_DIRS = new Set(['node_modules', 'dist', '.turbo']);
const EXTENSIONS = new Set(['.md', '.cjs', '.js', '.ts', '.tsx']);
// Test files contain intentional fixture strings (e.g. inputs the sanitizer
// is expected to strip). Rewriting them changes test semantics.
function isTestFile(name) {
return /\.test\.(c?js|tsx?)$/.test(name);
}
function buildPattern(cmdNames) {
// Empty input would compile `/gsd:()(?=[^a-zA-Z0-9_-]|$)/g`, which the regex
@@ -49,6 +63,19 @@ function readCmdNames() {
.map(f => f.replace(/\.md$/, ''));
}
function processFile(file, cmdNames) {
const pattern = buildPattern(cmdNames);
if (!pattern) return;
let src;
try { src = fs.readFileSync(file, 'utf-8'); } catch { return; }
const replaced = transformContent(src, cmdNames);
if (replaced !== src) {
fs.writeFileSync(file, replaced, 'utf-8');
const count = (src.match(pattern) || []).length;
console.log(` ${count} replacements: ${path.relative(path.join(__dirname, '..'), file)}`);
}
}
function processDir(dir, cmdNames) {
const pattern = buildPattern(cmdNames);
if (!pattern) return;
@@ -57,15 +84,10 @@ function processDir(dir, cmdNames) {
for (const e of entries) {
const full = path.join(dir, e.name);
if (e.isDirectory()) {
if (SKIP_DIRS.has(e.name)) continue;
processDir(full, cmdNames);
} else if (EXTENSIONS.has(path.extname(e.name))) {
const src = fs.readFileSync(full, 'utf-8');
const replaced = transformContent(src, cmdNames);
if (replaced !== src) {
fs.writeFileSync(full, replaced, 'utf-8');
const count = (src.match(pattern) || []).length;
console.log(` ${count} replacements: ${path.relative(path.join(__dirname, '..'), full)}`);
}
} else if (EXTENSIONS.has(path.extname(e.name)) && !isTestFile(e.name)) {
processFile(full, cmdNames);
}
}
}
@@ -75,7 +97,10 @@ if (require.main === module) {
for (const dir of SEARCH_DIRS) {
processDir(dir, cmdNames);
}
for (const file of TOP_LEVEL_FILES) {
processFile(file, cmdNames);
}
console.log('Done.');
}
module.exports = { transformContent, buildPattern };
module.exports = { transformContent, buildPattern, SKIP_DIRS };

View File

@@ -186,7 +186,7 @@ describe('loadConfig', () => {
// resolveModel/MODEL_PROFILES do not emit aliases when resolve_model_ids
// is "omit". Once a project is initialized, config.json is authoritative,
// because buildNewProjectConfig bakes user defaults into project config
// at /gsd:new-project time.
// at /gsd-new-project time.
it('pre-project: ignores user defaults and uses built-in defaults', async () => {
await writeUserDefaults({ resolve_model_ids: 'omit' });

View File

@@ -172,7 +172,7 @@ export async function loadConfig(projectDir: string, workstream?: string): Promi
}
// Project config exists — user-level defaults are ignored (CJS parity).
// `buildNewProjectConfig` already baked them into config.json at /gsd:new-project.
// `buildNewProjectConfig` already baked them into config.json at /gsd-new-project.
return mergeDefaults(parsed);
}

View File

@@ -1128,7 +1128,7 @@ export const statePlannedPhase: QueryHandler = async (args, projectDir, workstre
* Query handler for `state.milestone-switch` — resets STATE.md for a new
* milestone cycle (bug #2630 regression guard).
*
* The `/gsd:new-milestone` workflow only rewrote STATE.md's body (Current
* The `/gsd-new-milestone` workflow only rewrote STATE.md's body (Current
* Position section). The YAML frontmatter (`milestone`, `milestone_name`,
* `status`, `progress.*`) was never touched on a mid-flight switch, so queries
* that read frontmatter (`state.json`, `getMilestoneInfo`, every handler that
@@ -1151,7 +1151,7 @@ export const statePlannedPhase: QueryHandler = async (args, projectDir, workstre
*
* Sibling CJS parity: `cmdInitNewMilestone` in `init.cjs` is read-only (like
* the TS `initNewMilestone`). The workflow-level fix is to call
* `state.milestone-switch` from `/gsd:new-milestone` Step 5 in place of the
* `state.milestone-switch` from `/gsd-new-milestone` Step 5 in place of the
* manual body rewrite.
*/
export const stateMilestoneSwitch: QueryHandler = async (args, projectDir, workstream) => {

View File

@@ -649,7 +649,7 @@ export const verifySchemaDrift: QueryHandler = async (args, projectDir, workstre
*
* Non-blocking by contract: every failure mode returns a successful response
* with `{ skipped: true, reason }`. The post-execute drift gate in
* `/gsd:execute-phase` relies on this guarantee.
* `/gsd-execute-phase` relies on this guarantee.
*
* Delegates to the Node-side implementation in `bin/lib/drift.cjs` and
* `bin/lib/verify.cjs` via a child process so the drift logic stays in one

View File

@@ -39,8 +39,20 @@ const SEARCH_DIRS = [
path.join(ROOT, 'get-shit-done', 'templates'),
path.join(ROOT, 'get-shit-done', 'contexts'),
COMMANDS_DIR,
path.join(ROOT, 'agents'),
path.join(ROOT, 'sdk', 'src'),
];
const TOP_LEVEL_FILES = [
path.join(ROOT, '.clinerules'),
];
// Re-use SKIP_DIRS from the production script so the test's directory walker
// stays in lockstep with the fixer's. EXTENSIONS legitimately diverges (the
// guard scans only `.md`/`.cjs`/`.js` per the no-source-grep standard, while
// the fixer also rewrites `.ts`/`.tsx`), so it is not shared.
const { SKIP_DIRS } = require(path.join(ROOT, 'scripts', 'fix-slash-commands.cjs'));
// Discover user-facing markdown surfaces dynamically so a freshly added
// doc (a new RELEASE-*.md, a new top-level guide) is automatically scanned
// for namespace drift. A hand-curated list silently weakens drift detection
@@ -80,6 +92,11 @@ function discoverDocSearchFiles(root) {
const DOC_SEARCH_FILES = discoverDocSearchFiles(ROOT);
// Limited to .md (and pre-existing .cjs/.js) by the no-source-grep standard:
// markdown text IS the deployed product, but .ts/.tsx source must be guarded
// via runtime behavior. The fixer (scripts/fix-slash-commands.cjs) covers
// .ts/.tsx auto-rewrites at build time; idempotency (a no-op second run) is
// the runtime guard for those extensions.
const EXTENSIONS = new Set(['.md', '.cjs', '.js']);
function collectFiles(dir, results = []) {
@@ -87,7 +104,10 @@ function collectFiles(dir, results = []) {
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return results; }
for (const e of entries) {
const full = path.join(dir, e.name);
if (e.isDirectory()) collectFiles(full, results);
if (e.isDirectory()) {
if (SKIP_DIRS.has(e.name)) continue;
collectFiles(full, results);
}
else if (EXTENSIONS.has(path.extname(e.name))) results.push(full);
}
return results;
@@ -103,7 +123,10 @@ const cmdNames = fs.readdirSync(COMMANDS_DIR)
const retiredPattern = new RegExp(`/gsd:(${cmdNames.join('|')})(?=[^a-zA-Z0-9_-]|$)`);
const allFiles = SEARCH_DIRS.flatMap(d => collectFiles(d));
const allUserFacingFiles = allFiles.concat(DOC_SEARCH_FILES.filter((file) => fs.existsSync(file)));
const topLevelFiles = TOP_LEVEL_FILES.filter((file) => fs.existsSync(file));
const allUserFacingFiles = allFiles
.concat(topLevelFiles)
.concat(DOC_SEARCH_FILES.filter((file) => fs.existsSync(file)));
describe('slash-command namespace invariant (#2697)', () => {
test('commands/gsd/ directory contains known command files', () => {
@@ -183,7 +206,7 @@ describe('slash-command namespace invariant (#2697)', () => {
});
test('gsd-sdk and gsd-tools identifiers are not rewritten', () => {
for (const file of allFiles) {
for (const file of allUserFacingFiles) {
const src = fs.readFileSync(file, 'utf-8');
assert.ok(
!src.includes('/gsd:sdk'),