fix(#4324): stop the retired /gsd: prefix reaching users (#4712)

* test(#4324): prove colon tokens the installer cannot convert leak

Failing-first regression coverage for #4324. The install rewrite
(transformContentToHyphen) is gated on an exact match against the
commands/gsd stem list, so any /gsd:<token> whose token is not a
registered stem survives the install and reaches the user as the
deprecated colon form.

The gate is load-bearing -- it is the only thing protecting the
workflow DSL marker family (gsd:section, gsd:protected, gsd:loop-host,
gsd:guard, gsd:dispatch, gsd:plan-revision-conflicts), which
workflow-fragments parses as a literal. So this suite asserts the
shipped text is convertible rather than asserting the transform is
broad, and pins the marker family as explicit negative space.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#4324): stop unconvertible colon tokens reaching the user

The install rewrite is gated on an exact match against the commands/gsd
stem list, so a /gsd:<token> whose token is not a registered stem
survives the install and reaches the user as the deprecated colon form.
That gate is load-bearing -- it protects the gsd:section /
gsd:protected / gsd:loop-host marker family -- so the fix is in the
shipped text, and the source stays colon per CONTEXT.md's two-tier rule.

- quick-batch command + skill description: close the command token at a
  boundary so `/gsd:quick`-shaped converts instead of being skipped.
- gsd-code-fixer (both variants): execute-plan and diagnose-issues are
  workflows, not commands, so they never converted and rendered beside
  two hyphenated siblings on the same line. Name them as workflows.
- help topic-mode: the extraction rule hard-coded a colon prefix that
  the converted full.md never ships, so --brief could never match a
  signature line and silently fell back on every topic. Describe the
  signature line without a literal prefix.
- update.md: drop the prefix from prose describing a stale command.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#4324): add changeset fragment

pr:0 placeholder is backfilled with the real number once the PR exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#4324): locate the help summary per reference variant

Adversarial review finding. Restoring the signature-line match (the
#4324 fix) activated a latent defect in the clause next to it: compact
scope emitted "the single non-blank line immediately after" the
signature, and that clause is only correct for full.md.

full.compact.md puts the summary on the signature line itself, after an
em-dash, and its next non-blank line is an unrelated "Usage:" line. Both
variants ship and both are served, so before this commit the compact
variant would have emitted the wrong line as the summary. It was masked
until now only because the stale colon prefix meant no signature line
ever matched at all.

Name the two placements and pick per line, and say explicitly that a
Usage: line is never a summary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(#4324): de-vacuum the help parity check, narrow the marker waiver

Two adversarial review findings against the #4324 coverage.

The help-parity assertion went vacuous the moment the fix landed: once
topic.md stops spelling a literal prefix, the matched set is empty and
the assertion holds for any rewording, correct or not. It now also
asserts across BOTH served reference variants that each ships signature
lines under the hyphen prefix, that the two genuinely disagree about
where the summary sits, and that topic.md still names both placements
and the Usage: guard.

The marker waiver keyed on "sits inside an HTML comment", which waves
through a real broken reference that happens to be commented out --
`<!-- see /gsd:typo-cmd -->` scored clean. Enumerate the six marker
families instead. Verified the narrowed rule catches that probe and
still passes over the tree; it also surfaced a seventh family,
write-continue, that the broad rule was hiding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#4324): normalize the namespace in skill descriptions

Both hyphen-namespace skill converters ran the hyphen transform over the
body but rebuilt the frontmatter description from the raw field, so a
/gsd:<cmd> mention in a command description survived into the installed
SKILL.md -- the exact field the host's skill picker renders, which is
the surface this issue was filed about.

The local flat-command path was already correct because it rewrites the
whole file; only the skills path, used by a global install, was
affected. Confirmed by installing into a fake HOME before and after.

Fixed in both copies: bin/install.js and the src/ source of truth that
compiles into gsd-core/bin/lib.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(#4324): assert descriptions through the real converters

The previous version of this check called transformContentToHyphen on
the description line itself and passed, while a real install still
shipped the colon form -- the converter never calls that transform on
the description. It asserted a proxy for the behaviour instead of the
behaviour.

Drive convertClaudeCommandToClaudeSkill and
convertClaudeCommandToClineSkill over every registered command and
assert on the emitted description. Verified it fails against the
pre-fix converters and passes against the fixed ones.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#4324): regenerate skills after the description change

skills/<name>/SKILL.md is generated by gen-plugin-skills, not
hand-maintained, and lint:generated-sync caught the hand edit. The
regenerated file emits the hyphen form, which also corrects the
assumption behind the scan comment in the namespace test: skills/ is
runtime-emitter output, not colon source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(#4324): re-sanction normalizeKimiSkillName's real end line

The description-normalisation fix inserted five lines above
normalizeKimiSkillName in src/runtime-artifact-conversion.cts, moving its
closing brace from 635 to 640. MAJOR-1 pins that line deliberately, so the
planted violation landed INSIDE the exempted body and went unflagged --
0 !== 1.

Re-sanction the value rather than derive it: the array is named
sanctionedRealEndLines, and a pinned line that fails loudly on drift is
the design. Deriving it would remove the human check the name asks for.

Verified by executing all four MAJOR-1 rows against the real tree: each
planted violation is flagged at realEndLine+1 and each unmodified file
stays exempt.

Emitted-Drift-Ack-Growth: gsd-code-fixer.md — names execute-plan and diagnose-issues as workflows rather than as slash commands that do not exist
Emitted-Drift-Ack-Growth: gsd-code-fixer.compact.md — same rewording as its full sibling, kept byte-consistent with it
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#4324): backfill the changeset PR number

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-09-13 22:17:48 -04:00
committed by GitHub
parent 296f049fca
commit f334f277dd
11 changed files with 415 additions and 14 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 4712
---
**Slash-command suggestions no longer show the retired `/gsd:` form** — a handful of shipped command descriptions, agent bodies and workflow instructions used `/gsd:` tokens the installer could not convert, so they reached you as the deprecated colon form after a fresh install. One consequence was functional, not cosmetic: `/gsd-help --brief <topic>` looked for a signature line that the installed reference never renders, so every topic silently fell back to its first paragraph. (#4324)

View File

@@ -120,7 +120,7 @@ After applying each fix:
<step name="setup_worktree">
**Isolation: create a dedicated git worktree BEFORE touching any files.** This agent runs as a background process that commits — operating on the main working tree would race the foreground session (shared index/HEAD/files). Every instance runs in its own isolated worktree.
**Honor `workflow.use_worktrees` (the documented opt-out; the same flag the sibling writer workflows `/gsd:execute-phase`, `/gsd:execute-plan`, `/gsd:quick`, `/gsd:diagnose-issues` all honor — this is the only writer that hand-rolls its own worktree).** Read it directly via `node` from `.planning/config.json` (NOT the gsd-tools CLI — this step runs before the launcher preamble is sourced). When `false`: edit/commit in the main checkout directly — `wt="."`, `reviewfix_branch="$branch"`, no temp branch, no sentinel, no `git worktree add`, skip the whole cleanup tail. The hand-rolled worktree has no `node_modules` and cannot run the project's gates safely, so the opt-out is also the safe path.
**Honor `workflow.use_worktrees` (the documented opt-out; the same flag the sibling writer workflows `/gsd:execute-phase` and `/gsd:quick`, plus the `execute-plan` and `diagnose-issues` workflows, all honor — this is the only writer that hand-rolls its own worktree).** Read it directly via `node` from `.planning/config.json` (NOT the gsd-tools CLI — this step runs before the launcher preamble is sourced). When `false`: edit/commit in the main checkout directly — `wt="."`, `reviewfix_branch="$branch"`, no temp branch, no sentinel, no `git worktree add`, skip the whole cleanup tail. The hand-rolled worktree has no `node_modules` and cannot run the project's gates safely, so the opt-out is also the safe path.
```bash
USE_WORKTREES=$(node -e '

View File

@@ -217,8 +217,8 @@ If a finding references multiple files (in Fix section or Issue section):
This agent runs as a background process that makes commits. Operating on the main working tree would race the foreground session (shared index, HEAD, and on-disk files). Instead, every instance runs in its own isolated worktree.
**#2825: honor `workflow.use_worktrees`.** This is the ONLY writer that hand-rolls a git worktree
inside the agent prompt; every other writer path (`/gsd:execute-phase`, `/gsd:execute-plan`,
`/gsd:quick`, `/gsd:diagnose-issues`) reads `workflow.use_worktrees` and skips isolation when it is
inside the agent prompt; every other writer path (`/gsd:execute-phase`, `/gsd:quick`, and the
`execute-plan` / `diagnose-issues` workflows) reads `workflow.use_worktrees` and skips isolation when it is
`false`. Read the same flag here and, when it is `false`, edit and commit in the main checkout
directly (set `wt="."`, no `reviewfix_branch`, no recovery sentinel, no `git worktree add`, and skip
the cleanup tail — there is no worktree to remove). When the flag is not `false`, the transactional

View File

@@ -2023,7 +2023,12 @@ function convertClaudeCommandToClaudeSkill(content, skillName, runtime = null, c
const names = cmdNames || readGsdCommandNames();
const normalizedBody = transformContentToHyphen(body, names);
const description = extractFrontmatterField(frontmatter, 'description') || '';
// #4324: the description is the text the host's skill picker renders, so it
// needs the same hyphen normalisation the body gets — otherwise a `/gsd:<cmd>`
// mention in a command description ships the retired colon form to the user.
const description = transformContentToHyphen(
extractFrontmatterField(frontmatter, 'description') || '', names,
);
const argumentHint = extractFrontmatterField(frontmatter, 'argument-hint');
const agent = extractFrontmatterField(frontmatter, 'agent');
// #769: preserve context: from source command files so it is emitted into
@@ -2947,6 +2952,8 @@ function convertClaudeCommandToClineSkill(content, skillName, runtime = null, cm
let description = extractFrontmatterField(frontmatter, 'description');
if (!description) description = `Run GSD workflow ${skillName}.`;
description = toSingleLine(description);
// #4324: same reason as the Claude skill converter above.
description = transformContentToHyphen(description, names);
// Cline documented max is 1024 code points (not UTF-16 code units).
// Use Array.from to iterate by code point so that multibyte characters
// (e.g. emoji, astral-plane chars) are never split, which would produce

View File

@@ -1,6 +1,6 @@
---
name: gsd:quick-batch
description: Batch several /gsd:quick-shaped tasks together — planned, dispatched, and merged as one run
description: Batch several `/gsd:quick`-shaped tasks together — planned, dispatched, and merged as one run
argument-hint: "[--file <path>] [--jobs auto|N] [--validate] [--research] [--resume <batch-id>] [task list]"
allowed-tools:
- Read

View File

@@ -53,20 +53,30 @@ Emit a section from the full reference for the topic in `$ARGUMENTS`. Read `work
Use the canonical alias from the leftmost column. Use the literal heading text from the matched cell. State the scope you are about to emit.
5. Read `workflows/help/modes/full.md`. Strip `<reference>` / `</reference>` wrapper tags — never emit them. Apply the extraction rule for the matched table cell, modulated by scope:
5. Read `workflows/help/modes/full.md` — when `workflow.compact_content` is on, the installed reference is its `full.compact.md` sibling instead, and the two differ in where a command's one-line summary sits (see *Locating the summary* below). Strip `<reference>` / `</reference>` wrapper tags — never emit them. Apply the extraction rule for the matched table cell, modulated by scope:
5a. **Single section** (cell contains a single `` `## Heading` `` or `` `### Heading` ``):
- *Full scope:* emit from that heading up to (but not including) the next sibling or higher-level heading.
- *Compact scope:* emit the heading, then the first `` **`/gsd:...`** `` bold line within the section (the signature) and the single non-blank line immediately after it (the one-line summary). If the section has no `` **`/gsd:...`** `` bold line, emit the heading and the first paragraph.
- *Compact scope:* emit the heading, then the first **command-signature** bold line within the section (a `` **`<command> [args]`** `` line, whatever prefix the installed reference uses) together with its one-line summary, located per *Locating the summary* below. If the section has no command-signature bold line, emit the heading and the first paragraph.
5b. **Multiple sections joined by "plus"**: apply rule 5a to each listed section in document order and emit them sequentially with no gap between them.
5c. **Sub-block** (cell says `the /gsd:X block under ### Heading` or `the /gsd:X ... blocks under ### Heading`): within the named heading's section, start at each `` **`/gsd:X ...`** `` bold line.
- *Full scope:* stop immediately before the next `` **`/gsd:...`** `` bold line or the next heading, whichever comes first.
- *Compact scope:* emit the bold line and the single non-blank line immediately after it (the one-line summary).
5c. **Sub-block** (cell says `the <command> block under ### Heading` or `the <command> ... blocks under ### Heading`): within the named heading's section, start at each command-signature bold line for that command.
- *Full scope:* stop immediately before the next command-signature bold line or the next heading, whichever comes first.
- *Compact scope:* emit the bold line together with its one-line summary, located per *Locating the summary* below.
For cells listing multiple sub-blocks, emit them sequentially.
**Locating the summary.** The summary sits in one of two places, and which one depends on which
reference variant is installed — decide per signature line, by looking at the line itself:
- If the signature line continues past its closing `**` with an em-dash followed by prose, that
trailing prose **is** the summary. Emit that one line and stop; do not also emit the line
after it.
- Otherwise the summary is the single non-blank line immediately after the signature line. Emit
both lines.
A `Usage:` line is never a summary. If applying the second case would emit one, emit the
signature line alone.
6. After the section content, emit a single closing line:
```text

View File

@@ -526,7 +526,7 @@ already empty). Say nothing and continue — the update flow is unchanged.
Otherwise, render the report. Each entry carries `path`, `outcome`, and a
`warnings` array of `{code, detail}` produced by a compatibility pass against
the just-installed release — a renamed workflow it `@`-references, a `/gsd:`
the just-installed release — a renamed workflow it `@`-references, a slash
command that no longer exists, missing skill frontmatter. Render each entry's
warnings under its path. Entries whose `outcome` starts with `skipped_` will
**not** be restored; list them separately, with their reason, so the user knows

View File

@@ -1,6 +1,6 @@
---
name: gsd-quick-batch
description: "Batch several /gsd:quick-shaped tasks together — planned, dispatched, and merged as one run"
description: "Batch several `/gsd-quick`-shaped tasks together — planned, dispatched, and merged as one run"
argument-hint: "[--file <path>] [--jobs auto|N] [--validate] [--research] [--resume <batch-id>] [task list]"
allowed-tools:
- Read

View File

@@ -481,7 +481,12 @@ function convertClaudeCommandToClaudeSkill(content, skillName, runtime = null, c
const names = cmdNames || readGsdCommandNames();
const normalizedBody = transformContentToHyphen(body, names);
const description = extractFrontmatterField(frontmatter, 'description') || '';
// #4324: the description is the text the host's skill picker renders, so it
// needs the same hyphen normalisation the body gets — otherwise a `/gsd:<cmd>`
// mention in a command description ships the retired colon form to the user.
const description = transformContentToHyphen(
extractFrontmatterField(frontmatter, 'description') || '', names,
);
const argumentHint = extractFrontmatterField(frontmatter, 'argument-hint');
const agent = extractFrontmatterField(frontmatter, 'agent');
// #769: preserve context: from source command files so it is emitted into
@@ -1769,6 +1774,8 @@ function convertClaudeCommandToClineSkill(content, skillName, _runtime = null, c
let description = extractFrontmatterField(frontmatter, 'description');
if (!description) description = `Run GSD workflow ${skillName}.`;
description = toSingleLine(description);
// #4324: same reason as the Claude skill converter above.
description = transformContentToHyphen(description, names);
// Cline documented max is 1024 code points (not UTF-16 code units).
// Use Array.from to iterate by code point so that multibyte characters
// (e.g. emoji, astral-plane chars) are never split, which would produce

View File

@@ -1099,3 +1099,375 @@ describe('bug #3683 — workflow/reference colon-namespace leak (Claude local in
});
});
}
// ────────────────────────────────────────────────────────────────────────
// #4324 — colon tokens the install transform CANNOT convert leak to users
// ────────────────────────────────────────────────────────────────────────
//
// Companion to the `#3443` invariant above, and deliberately its mirror image.
// That one asserts the source stays COLON. This one asserts every colon token
// in the source is one the installer can actually turn into hyphen form.
//
// The install rewrite (`transformContentToHyphen`) is gated on an exact match
// against the `commands/gsd/*.md` stem list, so a `/gsd:<token>` whose token is
// not a registered stem survives the install untouched and reaches the user as
// the deprecated colon form. #4324 reported this as "all auto-suggestions are
// still using the outdated /gsd:".
//
// That gate is load-bearing and must NOT be widened: it is the only thing
// protecting the workflow DSL marker family (`gsd:section`, `gsd:protected`,
// `gsd:loop-host`, `gsd:guard`, `gsd:dispatch`, `gsd:plan-revision-conflicts`),
// which is parsed as a literal — `src/workflow-fragments.cts` pins
// `CLOSE_TAG = '/gsd:section'`. The fix therefore belongs in the shipped text,
// and this suite is what keeps it there.
{
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 FIXER = path.join(ROOT, 'scripts', 'fix-slash-commands.cjs');
// Drive the REAL production transform with the REAL roster. A roster invented
// here could only confirm what this test's author already believed about the
// gate (fixture-provenance rule, #2371).
const {
transformContentToHyphen,
buildColonPattern,
readCmdNames,
SKIP_DIRS,
} = require(FIXER);
const cmdNames = readCmdNames();
// Shipped surfaces whose text the runtime loads and shows the user.
//
// `skills/` is included even though the #3443 colon-invariant scan omits it —
// but not for the reason that scan omits it. skills/<name>/SKILL.md is
// GENERATED by scripts/gen-plugin-skills.cjs and is already emitted in hyphen
// form, so it is runtime-emitter output rather than colon source. Scanning it
// is a cheap belt-and-braces check that the generator never emits a colon
// token the installer could not convert; it currently contributes zero
// tokens, and that is the expected steady state.
const SCAN_DIRS = [
path.join(ROOT, 'commands', 'gsd'),
path.join(ROOT, 'agents'),
path.join(ROOT, 'gsd-core', 'workflows'),
path.join(ROOT, 'gsd-core', 'references'),
path.join(ROOT, 'gsd-core', 'templates'),
path.join(ROOT, 'skills'),
];
// Structural markers that legitimately use `gsd:` and are NOT slash commands.
// Enumerated BY FAMILY, never by "it sits in a comment": a blanket
// comment-context waiver would also wave through a genuinely broken command
// reference that merely happens to be commented out, e.g.
// `<!-- see /gsd:typo-cmd -->`, which is exactly the leak this guard exists
// to catch.
const COMMENT_MARKER_TOKENS = new Set([
'section', // <!-- gsd:section id="…" when="…" --> / <!-- /gsd:section -->
'protected', // <!-- gsd:protected:start --> / :end
'loop-host', // <!-- gsd:loop-host … -->
'plan-revision-conflicts', // <!-- gsd:plan-revision-conflicts:begin --> / :end
'live-dom-families', // <!-- gsd:live-dom-families -->
'write-continue', // <!-- gsd:write-continue … -->
]);
const BARE_MARKER_TOKENS = new Set([
'guard', // `# gsd:guard=orchestrator-cwd-drift`
'dispatch', // `[gsd:dispatch phase="…" plan="…"]`
]);
const IN_HTML_COMMENT = /<!--[^>]*gsd:/;
// `<!-- gsd: no compact sibling … -->` — the compact-content disclosure
// banner. Its token is empty, so it is matched by shape rather than by name.
const COMPACT_DISCLOSURE = /<!--\s*gsd:\s/;
function collect(dir, out = []) {
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return out; }
for (const e of entries) {
const full = path.join(dir, e.name);
if (e.isDirectory()) {
if (SKIP_DIRS.has(e.name)) continue;
collect(full, out);
} else if (e.name.endsWith('.md')) {
out.push(full);
}
}
return out;
}
const shippedFiles = SCAN_DIRS.flatMap((d) => collect(d));
// Same lookbehind as buildColonPattern, so match indices from the two regexes
// are directly comparable.
const ANY_COLON_TOKEN = /(?<![a-zA-Z0-9_-])gsd:([a-zA-Z0-9_-]*)/g;
describe('install-convertibility of colon tokens (#4324)', () => {
test('the scan corpus and the real roster are both populated', () => {
assert.ok(cmdNames.length > 0, 'commands/gsd/ must yield a non-empty roster');
assert.ok(shippedFiles.length > 0, 'SCAN_DIRS must yield shipped .md files');
assert.ok(cmdNames.includes('quick'), 'quick must be a registered command stem');
});
// ROW 1 — the failing-first regression test for #4324.
test('every gsd: token in user-facing source is convertible or a declared structural marker', () => {
const colonPattern = buildColonPattern(cmdNames);
assert.ok(colonPattern, 'buildColonPattern must compile for a non-empty roster');
const violations = [];
for (const file of shippedFiles) {
// allow-test-rule: source-text-is-the-product (#4324)
// These are shipped command/agent/workflow/skill bodies — their text IS
// what the runtime loads and renders, so the deployed text is the contract.
const src = fs.readFileSync(file, 'utf-8');
if (!src.includes('gsd:')) continue;
const convertibleAt = new Set(
[...src.matchAll(new RegExp(colonPattern.source, 'g'))].map((m) => m.index),
);
const lines = src.split(/\r?\n/);
for (const m of src.matchAll(ANY_COLON_TOKEN)) {
if (convertibleAt.has(m.index)) continue; // installer handles it
const lineNo = src.slice(0, m.index).split(/\r?\n/).length;
const line = lines[lineNo - 1] || '';
if (BARE_MARKER_TOKENS.has(m[1])) continue; // structural marker
const inComment = IN_HTML_COMMENT.test(line);
if (inComment && COMMENT_MARKER_TOKENS.has(m[1])) continue;
if (inComment && m[1] === '' && COMPACT_DISCLOSURE.test(line)) continue;
violations.push(
`${path.relative(ROOT, file)}:${lineNo}: "${m[0]}" in "${line.trim().slice(0, 90)}"`,
);
}
}
assert.deepEqual(
violations,
[],
`Found ${violations.length} colon token(s) the install transform cannot convert — ` +
`they reach the user as the deprecated /gsd: form (#4324).\n` +
`Fix the SHIPPED TEXT, not the transform: the command-stem gate protects the ` +
`gsd:section / gsd:protected / gsd:loop-host marker family.\n` +
`Either close the command token at a boundary ("\`/gsd:quick\`-shaped", not ` +
`"/gsd:quick-shaped"), stop rendering a non-command as a slash command, or ` +
`declare a new structural marker in BARE_MARKER_TOKENS.\n` +
`Offenders:\n ${violations.join('\n ')}`,
);
});
// ROW 2 — the reported symptom, asserted at the surface the user sees AND
// through the code that actually produces it.
//
// The first version of this test called `transformContentToHyphen` on the
// description line directly and passed — while a real install still shipped
// the colon form, because both hyphen-namespace skill converters rebuild
// `description:` from the raw frontmatter field and never run the transform
// on it. That is the difference between asserting the identity and asserting
// a proxy for it: drive the converter, or the next converter that forgets to
// normalise a field ships the same bug again (#4324).
test('installed skill descriptions carry no colon form', () => {
// allow-test-rule: integration-test-input (#4324)
// The command files are passed to the converters as DATA — real fixture
// input to the transformation under test — and the assertion is on the
// converter's emitted frontmatter, which is the text the host's skill
// picker renders and therefore the deployed contract itself.
const {
convertClaudeCommandToClaudeSkill,
convertClaudeCommandToClineSkill,
} = require(path.join(ROOT, 'bin', 'install.js'));
const converters = [
['claude', convertClaudeCommandToClaudeSkill],
['cline', convertClaudeCommandToClineSkill],
];
const offenders = [];
let checked = 0;
for (const stem of cmdNames) {
const file = path.join(ROOT, 'commands', 'gsd', `${stem}.md`);
if (!fs.existsSync(file)) continue;
const src = fs.readFileSync(file, 'utf-8');
for (const [label, convert] of converters) {
const emitted = convert(src, `gsd-${stem}`, null, cmdNames);
const descLine = emitted
.split(/\r?\n/)
.find((l) => l.startsWith('description:'));
if (!descLine) continue;
checked += 1;
if (/gsd:/.test(descLine)) {
offenders.push(
`${label} · commands/gsd/${stem}.md → ${descLine.trim().slice(0, 110)}`,
);
}
}
}
// Guard against the whole loop silently doing nothing.
assert.ok(
checked >= cmdNames.length,
`expected at least one emitted description per command per converter; checked ${checked}`,
);
assert.deepEqual(
offenders,
[],
`A converter emitted a skill description still carrying the /gsd: colon ` +
`form. This is the picker text #4324 reported, and it reaches the user on ` +
`every install.\n ${offenders.join('\n ')}`,
);
});
// ROW 3 — cross-surface parity, across BOTH served reference variants.
// topic.md tells the model which bold signature line to extract AND where
// that line's one-line summary sits. Two things can go wrong, and both did:
// 1. a literal prefix baked into the rule can never match the installed
// reference, which is converted to hyphen form at install time; and
// 2. the two served variants put the summary in DIFFERENT places —
// full.md on the following line, full.compact.md trailing an em-dash on
// the signature line itself. A rule that knows only one shape emits the
// following `Usage:` line as if it were the summary.
test('help topic-mode rule covers every served reference variant', () => {
const modes = path.join(ROOT, 'gsd-core', 'workflows', 'help', 'modes');
const topicPath = path.join(modes, 'topic.md');
assert.ok(fs.existsSync(topicPath), 'help/modes/topic.md must exist');
const variants = ['full.md', 'full.compact.md']
.map((name) => ({ name, file: path.join(modes, name) }))
.filter((v) => fs.existsSync(v.file));
assert.equal(variants.length, 2, 'both full.md and full.compact.md must ship');
const shapes = new Set();
for (const variant of variants) {
// allow-test-rule: source-text-is-the-product (#4324)
// The installed reference text IS what the model reads; its rendered
// shape is the contract topic.md's extraction rule is written against.
const converted = transformContentToHyphen(
fs.readFileSync(variant.file, 'utf-8'), cmdNames,
);
const lines = converted.split(/\r?\n/);
const signatureLines = lines.filter((l) => /^\*\*`\/gsd[-:]/.test(l));
assert.ok(
signatureLines.length > 0,
`${variant.name} must contain command-signature bold lines`,
);
const prefixes = new Set(
signatureLines.map((l) => l.match(/^\*\*`(\/gsd[-:])/)[1]),
);
assert.deepEqual(
[...prefixes], ['/gsd-'],
`${variant.name} must ship only the hyphen signature prefix after install conversion`,
);
for (const l of signatureLines) {
shapes.add(/\*\*\s+—\s+\S/.test(l) ? 'same-line' : 'next-line');
}
}
// The variants genuinely disagree. This is WHY topic.md's rule must branch,
// and it is asserted rather than assumed: if the corpus ever collapses to a
// single placement, the two-case rule can be simplified — deliberately.
assert.deepEqual(
[...shapes].sort(), ['next-line', 'same-line'],
'the served variants must between them use both summary placements',
);
// allow-test-rule: source-text-is-the-product (#4324)
// topic.md is shipped workflow text the runtime loads; its wording is the
// deployed contract, so the wording is what must be asserted.
const topic = fs.readFileSync(topicPath, 'utf-8');
const topicConverted = transformContentToHyphen(topic, cmdNames);
// (a) no literal command prefix baked into a bold-signature instruction —
// such a prefix can never match the converted reference.
const instructed = [...topicConverted.matchAll(/\*\*`(\/gsd[-:])/g)].map((m) => m[1]);
assert.deepEqual(
instructed, [],
`topic.md bakes the literal prefix(es) ${JSON.stringify(instructed)} into a ` +
`bold-signature instruction, but the installed reference ships '/gsd-' after ` +
`conversion — the match can never succeed and --brief silently falls back to ` +
`"heading + first paragraph" on every topic (#4324).`,
);
// (b) the rule must actually COVER both placements. Without this, (a) is
// vacuous: any rewording that merely avoids spelling a literal prefix
// would pass, including one that handles only a single variant.
for (const [needle, why] of [
[/Locating the summary/, 'a named sub-rule that says where the summary sits'],
[/em-dash/, 'the full.compact.md case — summary trailing an em-dash on the signature line'],
[/single non-blank line immediately after/, 'the full.md case — summary on the following line'],
[/`Usage:` line is never a summary/, 'the guard against emitting a Usage: line as the summary'],
]) {
assert.match(
topic, needle,
`topic.md must retain ${why}; without it the compact variant emits the ` +
`following "Usage:" line as if it were the one-line summary (#4324).`,
);
}
});
});
// The gate's own boundary behaviour. These characterize WHY the residuals above
// escape, so a future editor cannot "fix" #4324 by widening the gate without
// reding the marker guard directly below.
describe('colon-gate boundary behaviour (#4324)', () => {
test('a command stem at a token boundary converts', () => {
for (const input of ['/gsd:quick ', '`/gsd:quick`', '/gsd:quick.', '/gsd:quick']) {
assert.match(
transformContentToHyphen(input, cmdNames),
/gsd-quick/,
`expected ${JSON.stringify(input)} to convert`,
);
}
});
test('a command stem followed by a hyphen is not a command token', () => {
// `quick` is a stem, but `quick-shaped` is not, and the right-hand lookahead
// rejects the following `-`. Nothing matches — this is the #4324 mechanism.
assert.equal(transformContentToHyphen('/gsd:quick-shaped', cmdNames), '/gsd:quick-shaped');
});
test('longest-stem-first ordering is preserved', () => {
assert.equal(transformContentToHyphen('/gsd:quick-batch', cmdNames), '/gsd-quick-batch');
});
test('an empty roster converts nothing', () => {
assert.equal(buildColonPattern([]), null);
assert.equal(transformContentToHyphen('/gsd:quick', []), '/gsd:quick');
});
test('non-command gsd- identifiers are left alone', () => {
for (const input of ['/gsd-sdk', '/gsd-tools']) {
assert.equal(transformContentToHyphen(input, cmdNames), input);
}
});
// NEGATIVE SPACE — the guard that makes "just un-gate the regex" fail loudly.
test('structural markers survive the install transform unchanged', () => {
const markers = [
'<!-- gsd:section id="converge-loop" when="state:plan-strategy-converge" -->',
'<!-- /gsd:section -->',
'<!-- gsd:protected:start -->',
'<!-- gsd:protected:end -->',
'<!-- gsd:loop-host',
'<!-- gsd:plan-revision-conflicts:begin -->',
'<!-- gsd:plan-revision-conflicts:end -->',
'# gsd:guard=orchestrator-cwd-drift',
'[gsd:dispatch phase="{phase_number}" plan="{plan_id}"]',
'<!-- gsd:live-dom-families -->',
];
for (const marker of markers) {
assert.equal(
transformContentToHyphen(marker, cmdNames),
marker,
`structural marker must survive byte-identical: ${marker}`,
);
// CRLF variant — same verdict.
assert.equal(
transformContentToHyphen(`${marker}\r\n`, cmdNames),
`${marker}\r\n`,
`structural marker must survive byte-identical under CRLF: ${marker}`,
);
}
});
});
}

View File

@@ -130,7 +130,7 @@ describe('findSlugDerivationDrift — MAJOR-1: allowlist exemption is scoped to
const sanctionedRealEndLines = [
{ file: path.join('src', 'core-utils.cts'), fn: 'generateSlugInternal', realEndLine: 199 },
{ file: path.join('src', 'gsd2-import.cts'), fn: 'slugify', realEndLine: 103 },
{ file: path.join('src', 'runtime-artifact-conversion.cts'), fn: 'normalizeKimiSkillName', realEndLine: 635 },
{ file: path.join('src', 'runtime-artifact-conversion.cts'), fn: 'normalizeKimiSkillName', realEndLine: 640 },
{ file: path.join('scripts', 'generate-package-identity.cjs'), fn: 'slugifyPackageName', realEndLine: 42 },
];