fix(3735): add surface to PROFILES.core (restore ADR-0011 expand contract) (#3744)

* test(3735): add failing test that PROFILES.core includes surface

Regression test asserting that resolveProfile({ modes: ['core'] }) includes
'surface' in its transitive closure — the ADR-0011 contract that --profile=core
users can expand via /gsd:surface enable <cluster>. Also updates stale
hardcoded skill-count assertions (7→8) across install-minimal*.test.cjs and
install-profiles-resolve.test.cjs to reflect the correct post-fix baseline.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(3735): add 'surface' to PROFILES.core to restore ADR-0011 expand contract

PROFILES.core omitted 'surface', silently breaking the documented contract
in ADR-0011 that --profile=core users can expand their skill surface via
/gsd:surface enable <cluster>. The sub-command is only available if surface.md
is staged — which requires it to appear in the resolved set for the core profile.

Added 'surface' to both PROFILES.core and PROFILES.standard (standard is a
documented superset of core; omitting it from standard would break the
"standard must include all core skills" invariant and the resolveProfile tests).

The MINIMAL_SKILL_ALLOWLIST back-compat shim is derived from PROFILES.core so
it picks up surface automatically, ensuring --minimal and --core-only installs
also stage surface.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(3735): update changeset to reference PR #3744

The fix commit landed with the linked-issue number as the pr: field
placeholder. Updating to the actual PR number now that the PR is open.

* docs(install-profiles): fix stale 'six skills' count in module header comment

After #3735 added surface to PROFILES.core the skill count became eight,
but the module-level comment still said "six skills covering the main project
loop". Update the description to reflect the correct count and reference the
ADR-0011 expand contract that surface fulfils.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-05-20 23:10:09 -04:00
committed by GitHub
parent 86e067924f
commit 26773bd669
5 changed files with 71 additions and 13 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 3744
---
`gsd:surface` is now part of the `core` install profile — the ADR-0011 `core → expand via /gsd:surface enable` contract was silently broken.

View File

@@ -9,7 +9,7 @@
* dropped skills when users stack multiple plugins (#3408).
*
* Profile model: three named profiles replace the old minimal/full binary:
* - core — six skills covering the main project loop
* - core — eight skills covering the main project loop (includes surface for ADR-0011 expand contract)
* - standard — core + phase management and workspace skills
* - full — all skills (previous default, '*' sentinel)
* Profiles compose: --profile=core,audit resolves to union(closure(core), closure(audit)).
@@ -64,6 +64,7 @@ const PROFILES = Object.freeze({
'phase',
'help',
'update',
'surface',
]),
standard: Object.freeze([
// Core loop
@@ -73,6 +74,7 @@ const PROFILES = Object.freeze({
'execute-phase',
'help',
'update',
'surface',
// Phase management (hot nodes from audit — required by 38+ skills)
'phase',
'review',

View File

@@ -0,0 +1,49 @@
'use strict';
/**
* Regression test for #3735: PROFILES.core must include 'surface' in its
* resolved closure so that --profile=core users can expand via
* /gsd:surface enable <cluster> — the advertised use-case from ADR-0011.
*
* Stage 2 (RED): This test must fail before the fix is applied.
* Stage 3 (GREEN): This test must pass after 'surface' is added to PROFILES.core.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const path = require('path');
const {
PROFILES,
resolveProfile,
loadSkillsManifest,
} = require('../get-shit-done/bin/lib/install-profiles.cjs');
const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd');
describe('PROFILES.core — ADR-0011 expand contract', () => {
test("PROFILES.core includes 'surface' so users can expand via /gsd:surface enable", () => {
const manifest = loadSkillsManifest(REAL_COMMANDS_DIR);
const result = resolveProfile({ modes: ['core'], manifest });
assert.ok(result.skills instanceof Set,
'resolveProfile must return a skills Set for core profile');
// The primary assertion: surface must be in the resolved closure.
// ADR-0011 documents that --profile=core users expand via /gsd:surface enable <cluster>.
// That sub-command is only available if surface.md is staged — which requires it to be
// in the resolved set for the core profile.
assert.ok(result.skills.has('surface'),
`PROFILES.core resolved closure must include 'surface'; got: [${[...result.skills].sort().join(', ')}]`);
});
// Counter-test: 'forensics' is NOT in core — proves the assertion above is selective,
// not vacuously true for all skills.
test("PROFILES.core does NOT include 'forensics' (selective assertion counter-check)", () => {
const manifest = loadSkillsManifest(REAL_COMMANDS_DIR);
const result = resolveProfile({ modes: ['core'], manifest });
assert.ok(result.skills instanceof Set);
assert.ok(!result.skills.has('forensics'),
`'forensics' should NOT be in core closure — it is a specialist skill, not a core loop skill`);
});
});

View File

@@ -69,7 +69,7 @@ describe('install-profiles: MINIMAL_SKILL_ALLOWLIST', () => {
test('contains exactly the main-loop core (frozen)', () => {
assert.deepStrictEqual(
[...MINIMAL_SKILL_ALLOWLIST].sort(),
['discuss-phase', 'execute-phase', 'help', 'new-project', 'phase', 'plan-phase', 'update'],
['discuss-phase', 'execute-phase', 'help', 'new-project', 'phase', 'plan-phase', 'surface', 'update'],
);
assert.ok(Object.isFrozen(MINIMAL_SKILL_ALLOWLIST));
});
@@ -128,7 +128,7 @@ describe('install-profiles: stageSkillsForMode', () => {
function createFixtureSkillsDir() {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-stage-fixture-'));
for (const name of ['plan-phase', 'execute-phase', 'autonomous', 'do', 'help',
'new-project', 'phase', 'discuss-phase', 'update', 'progress']) {
'new-project', 'phase', 'discuss-phase', 'update', 'progress', 'surface']) {
fs.writeFileSync(path.join(tmp, `${name}.md`), `# ${name}\n`);
}
return tmp;
@@ -152,7 +152,7 @@ describe('install-profiles: stageSkillsForMode', () => {
assert.deepStrictEqual(
fs.readdirSync(staged).sort(),
['discuss-phase.md', 'execute-phase.md', 'help.md', 'new-project.md',
'phase.md', 'plan-phase.md', 'update.md'],
'phase.md', 'plan-phase.md', 'surface.md', 'update.md'],
);
} finally {
fs.rmSync(src, { recursive: true, force: true });
@@ -395,23 +395,23 @@ describe('install: manifest records mode for both profiles', () => {
assert.ok(r.agentCount > 0);
});
test('--minimal records mode: "minimal" with exactly 7 skills and 0 agents', () => {
test('--minimal records mode: "minimal" with exactly 8 skills and 0 agents', () => {
const r = manifestModeAfterInstall(['--minimal']);
assert.strictEqual(r.mode, 'minimal');
assert.strictEqual(r.skillCount, 7);
assert.strictEqual(r.skillCount, 8);
assert.strictEqual(r.agentCount, 0);
});
test('--core-only is an alias for --minimal', () => {
const r = manifestModeAfterInstall(['--core-only']);
assert.strictEqual(r.mode, 'minimal');
assert.strictEqual(r.skillCount, 7);
assert.strictEqual(r.skillCount, 8);
assert.strictEqual(r.agentCount, 0);
});
});
describe('install-minimal-backcompat: PROFILES.core matches MINIMAL_SKILL_ALLOWLIST', () => {
test('PROFILES.core contains the same 7 skills as MINIMAL_SKILL_ALLOWLIST', () => {
test('PROFILES.core contains the same 8 skills as MINIMAL_SKILL_ALLOWLIST', () => {
assert.deepStrictEqual(
[...PROFILES.core].sort(),
[...MINIMAL_SKILL_ALLOWLIST].sort(),
@@ -442,10 +442,10 @@ describe('install-minimal-backcompat: --minimal and --profile=core produce same
}
}
test('--minimal produces mode "minimal" with exactly 7 skills', () => {
test('--minimal produces mode "minimal" with exactly 8 skills', () => {
const r = installAndGetManifest(['--minimal']);
assert.strictEqual(r.mode, 'minimal');
assert.strictEqual(r.skillCount, 7);
assert.strictEqual(r.skillCount, 8);
});
test('--minimal writes .gsd-profile marker "core"', () => {

View File

@@ -313,7 +313,7 @@ describe('PROFILES map', () => {
assert.ok('full' in PROFILES, 'PROFILES.full missing');
});
test('PROFILES.core contains the 7 main-loop skills (including phase)', () => {
test('PROFILES.core contains the 8 main-loop skills (including phase and surface)', () => {
const core = PROFILES.core;
assert.ok(Array.isArray(core), 'core should be an array');
const sorted = [...core].sort();
@@ -324,6 +324,7 @@ describe('PROFILES map', () => {
'new-project',
'phase',
'plan-phase',
'surface',
'update',
]);
});
@@ -354,12 +355,13 @@ describe('resolveProfile', () => {
assert.strictEqual(result.skills, '*');
});
test('resolves core profile — returns 7+ skills, all base stems present', () => {
test('resolves core profile — returns 8+ skills, all base stems present', () => {
const manifest = loadSkillsManifest(REAL_COMMANDS_DIR);
const result = resolveProfile({ modes: ['core'], manifest });
assert.strictEqual(result.name, 'core');
assert.ok(result.skills instanceof Set, 'skills should be a Set');
assert.ok(result.skills.size >= 7, `core closure should have >=7 skills, got ${result.skills.size}`);
// core has 8 base skills (includes surface as of #3735).
assert.ok(result.skills.size >= 8, `core closure should have >=8 skills, got ${result.skills.size}`);
for (const s of PROFILES.core) {
assert.ok(result.skills.has(s), `core closure should include ${s}`);
}