* chore(#604): rename get-shit-done/ runtime directory to gsd-core/ Renames the installed runtime directory `get-shit-done/` to `gsd-core/` so the on-disk name matches the package (`@opengsd/gsd-core`), repo, and binary (`gsd-tools`). The npm package name and binary are unchanged; npx/npm consumers are unaffected. Mechanical (bulk, ~90% of the diff): - `git mv get-shit-done gsd-core` - Swept path/identifier references across the repo via `perl -pe 's/get-shit-done(?!-\w)/gsd-core/g'`. The negative lookahead preserves the five legitimate slug variants that are NOT the directory: get-shit-done-{OLD,cc,classic,cli,redux} (old package/repo names). - Build/manifest wiring: package.json (bin, files, coverage globs), tsconfig.build.json (outDir), ~86 .gitignore build-output entries, stryker.config.mjs, scan-ignore files, install.js path strings. - Frozen (not rewritten): CHANGELOG.md history; translated docs (README.<locale>.md and docs/{ja-JP,ko-KR,pt-BR,zh-CN}/). New logic (review here): - src/installer-migrations/003-rename-get-shit-done-to-gsd-core.cts: a proper ADR-0008 installer migration. On upgrade it walks the legacy `~/.claude/get-shit-done/` tree, classifies each file via the prior install manifest, and emits remove-managed / backup-and-remove for managed files while PRESERVING unknown user-added files. Symlink-safe (skips a symlinked root and symlinked entries; bounds-checks every path under configDir). The framework rolls back on install failure. Emptied dirs may remain (framework has no recursive dir-removal primitive) — documented. - scripts/lint-legacy-dir-name.cjs: CI regression guard forbidding the bare `get-shit-done` directory token (split token to avoid self-match; case- insensitive; `(?!-\w)` lookahead allows the slug variants; allowlists CHANGELOG, translated docs, and `gsd-allow-legacy-name` marker lines). Wired into the lint-tests CI job. - Restored scripts/lint-package-identity-drift.cjs detection regexes (the mechanical sweep had wrongly rewritten the old-name patterns it exists to detect) and marked them as intentional legacy references. - TDD tests for the migration and the guard; do.md slash-command guard regex tightened so a `/gsd-core/bin` path segment is not mistaken for a command; changeset + docs/installer-migrations.md row added. Breaking: the installed runtime path moves `~/.claude/get-shit-done/` -> `~/.claude/gsd-core/`. Migration 003 removes the stale legacy dir's managed files (preserving user files) on upgrade. Users with custom hooks/configs hardcoding the old path must update them. Closes #604 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#604): unsweep pending changesets + allowlist injection-example docs CI fixes for the rename PR: - Do not sweep pending .changeset/*.md (ephemeral release-note fragments, like CHANGELOG); reverted those body edits so 5 pre-existing malformed fragments (missing type/pr) no longer enter the PR diff and trip docs-lint. Allowlisted .changeset/ in the legacy-name guard accordingly. - Allowlisted TEST-EXAMPLES.md and docs/explanation/security-model.md in prompt-injection-scan.sh: they contain intentional injection examples / security-model prose; the path-reference rewrites are kept. CodeQL alerts on this PR are pre-existing (alert lines unchanged by this PR; none in the new migration/guard) and are out of scope for the rename. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#604): resolve CodeQL alerts surfaced on this PR The rename diff touched files carrying pre-existing CodeQL findings; per the no-pre-existing-dismissal rule, fixing every surfaced alert rather than waving them off. All behavior-preserving: - scripts/ci-test-scope.cjs: build the config-path match from string .includes() instead of a RegExp over an arg-derived value (js/regex-injection). - src/profile-output.cts: escape backslashes before pipe-escaping desc/safeName so the table-cell escape is complete (js/incomplete-sanitization). - tests/{bug-2643,bug-2808,docs-parity-live-registry}: two-pass HTML-comment strip so a bare/unclosed `<!--` cannot survive (js/incomplete-multi-character-sanitization). - tests/inline-plan-threshold: drop the no-op `\s`->`\s` identity replace, keep the meaningful POSIX-class conversion (js/identity-replacement). Verified: build:lib green; the touched test files + ci-test-scope + profile-output suites pass; lint:legacy-name clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#604): correctly resolve remaining CodeQL alerts (regex-injection + sanitization) The prior commit's fixes for two alerts were ineffective: - ci-test-scope.cjs js/regex-injection: the alert is the CLI-arg-derived `file` reaching static regex `.test(file)` calls (not the config rule). Removed ALL regex over file/t — startsWith/includes/=== string checks + an isWindowsHint helper — so there is no regex sink for the tainted value. - js/incomplete-multi-character-sanitization (3 test files): a single `.replace(/<!--...-->/g,'')` can let `<!--` re-form. Replaced with a fixpoint loop (replace until stable) plus a final bare-opener strip. Verified: no regex over file/t remains; ci-test-scope + the 3 test suites pass; lint:legacy-name clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#604): make ci-test-scope + comment-strippers regex-free to clear CodeQL CodeQL flags the regex PATTERNS syntactically (regex-injection on the --files arg split; incomplete-multi-character-sanitization on the <!--...--> replace), so loop fixes do not satisfy it. Made these paths regex-free: - ci-test-scope.cjs splitFiles: char-by-char separator tokenizer (no /[,\\s]+/). - 3 test files: indexOf/slice HTML-comment stripper (no .replace(/<!--/)). Behavior preserved; ci-test-scope + the 3 suites pass; guard clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#604): unblock security base64 scan on the large rename diff The security job hit its 10m timeout: base64-scan.sh choked on the binary test fixture tests/feat-3594-parser-property-style.test.cjs (embedded NUL/ non-UTF8 bytes -> thousands of bogus blobs + "ignored null byte" warnings), and the ~800-file rename diff is slow to scan regardless. - scripts/base64-scan.sh: skip binary-by-content files (grep -Iq .) — they can't carry base64-obfuscated *text* and feeding NUL bytes through the per-line scanner is pathologically slow. collect_files already filtered binary *extensions*; this catches binary *content* in text extensions. - .github/workflows/security-scan.yml: raise the security job timeout 10m->30m to accommodate very large diffs (the scan itself is unchanged). Verified locally: scan skips the fixture, 0 "ignored null byte" warnings, 0 findings, exit 0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#604): sweep get-shit-done refs introduced by merging next The branch was updated with next (#614/#384/#618 etc.), which reference the get-shit-done/ dir (still named that on next). Swept the stale references in the merged files to gsd-core so the rename stays consistent and lint:legacy-name passes: - commands/gsd/discuss-phase.md (runtime-launcher shim paths) - src/core.cts (getAgentsDir layout comments) - tests/bug-384-agents-runtime-aware.test.cjs (require path to runtime lib) Verified: guard 0 violations; build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#604): exclude gsd-core/ path segments from bug-3683 command cross-ref invariant The #614 runtime-launcher shim added to discuss-phase.md references `${_GSD_RUNTIME_ROOT}/gsd-core/bin/...`. bug-3683's REF_PATTERN excluded path-y refs only via lookbehind, but `}` precedes `/gsd-core/` in the shim, so it mis-read the directory path as a dangling `/gsd-core` command ref (same class as the #604 bug-2954 fix). Added a trailing `(?![\w-]*\/)` so `/gsd-<x>/...` path segments are not treated as slash-command references. Verified locally on BOTH platforms before pushing: - mac (node 26) full suite: 0 failures - gsd-test-runner (linux, node22 image) full suite: 0 failures - bug-3683 + bug-2954 pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#604): lazily resolve findProjectRoot in gsd-tools (harden flaky CI) CI intermittently failed state.test's gsd-tools subprocess with "findProjectRoot is not a function" (flip-flopping across legs; not reproducible on mac full suite, gsd-test linux full suite, test:unit, or state.test x8). findProjectRoot is a re-export from core.cjs (sourced from project-root.cjs); binding it via destructure at module-load can be undefined under a load-ordering edge. Resolve it lazily at call time via a small wrapper so the lookup happens after core.cjs is fully initialized. Verified green on BOTH platforms before pushing: - mac (node 26) full suite: 0 failures - gsd-test-runner (linux, node22) full suite: 0 failures - state.test.cjs: 106/106; gsd-tools loads cleanly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#604): allowlist verification-patterns.md placeholder examples in secret scan The rename git-mv'd references/verification-patterns.md into gsd-core/, pulling it into the secret-scan diff. It documents stub/placeholder RED-FLAG env-var examples (illustrative Stripe test-key / database-URL / API-key placeholders) — not real credentials. Added it to .secretscanignore with the strict annotation, mirroring the existing gsd-core/workflows/plan-phase.md exception. Verified locally: secret-scan-lint --strict OK; secret-scan --diff origin/next exits 0 with 0 findings. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
829 lines
31 KiB
JavaScript
829 lines
31 KiB
JavaScript
'use strict';
|
|
/**
|
|
* Runtime Artifact Layout Module (ADR-3660) — surface seam.
|
|
* Consolidated from: surface-apply, surface-resolve, surface-state,
|
|
* surface-clusters, surface-list (5 files deleted).
|
|
* See also: runtime-artifact-layout.test.cjs, runtime-artifact-layout-install-profiles.test.cjs
|
|
*/
|
|
|
|
const { test, describe } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const os = require('os');
|
|
|
|
const { writeSurface, readSurface, resolveSurface, listSurface, applySurface } = require('../gsd-core/bin/lib/surface.cjs');
|
|
const { loadSkillsManifest, writeActiveProfile, resolveProfile } = require('../gsd-core/bin/lib/install-profiles.cjs');
|
|
const { resolveRuntimeArtifactLayout } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs');
|
|
const { CLUSTERS, allClusteredSkills } = require('../gsd-core/bin/lib/clusters.cjs');
|
|
const { createTempDir, cleanup } = require('./helpers.cjs');
|
|
|
|
const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd');
|
|
|
|
// ─── helpers ────────────────────────────────────────────────────────────────
|
|
|
|
function tmpDir(prefix) {
|
|
return fs.mkdtempSync(path.join(os.tmpdir(), prefix || 'gsd-ral-surf-'));
|
|
}
|
|
|
|
function createFixtureRuntime() {
|
|
const base = createTempDir('gsd-surface-apply-');
|
|
const runtimeConfigDir = base;
|
|
const commandsDir = path.join(runtimeConfigDir, 'commands', 'gsd');
|
|
const agentsDir = path.join(runtimeConfigDir, 'agents');
|
|
fs.mkdirSync(commandsDir, { recursive: true });
|
|
fs.mkdirSync(agentsDir, { recursive: true });
|
|
return { base, runtimeConfigDir, commandsDir, agentsDir };
|
|
}
|
|
|
|
function realManifest() {
|
|
return loadSkillsManifest(REAL_COMMANDS_DIR);
|
|
}
|
|
|
|
function readFrontmatterDescription(markdown) {
|
|
const lines = markdown.split('\n');
|
|
if (lines[0].trim() !== '---') return '';
|
|
for (let i = 1; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
if (line.trim() === '---') break;
|
|
const sep = line.indexOf(':');
|
|
if (sep === -1) continue;
|
|
const key = line.slice(0, sep).trim();
|
|
if (key !== 'description') continue;
|
|
return line.slice(sep + 1).trim();
|
|
}
|
|
return '';
|
|
}
|
|
|
|
// ─── applySurface ────────────────────────────────────────────────────────────
|
|
|
|
describe('applySurface', () => {
|
|
test('core profile: only core skills appear in commandsDir', (t) => {
|
|
const { base, runtimeConfigDir, commandsDir } = createFixtureRuntime();
|
|
t.after(() => cleanup(base));
|
|
writeActiveProfile(runtimeConfigDir, 'core');
|
|
writeSurface(runtimeConfigDir, {
|
|
baseProfile: 'core',
|
|
disabledClusters: [],
|
|
explicitAdds: [],
|
|
explicitRemoves: [],
|
|
});
|
|
const manifest = loadSkillsManifest(REAL_COMMANDS_DIR);
|
|
const layout = resolveRuntimeArtifactLayout('claude', runtimeConfigDir, 'local');
|
|
const resolved = applySurface(runtimeConfigDir, layout, manifest, CLUSTERS);
|
|
|
|
const files = fs.readdirSync(commandsDir).filter(f => f.endsWith('.md'));
|
|
for (const file of files) {
|
|
assert.ok(fs.existsSync(path.join(REAL_COMMANDS_DIR, file)), `unexpected file: ${file}`);
|
|
}
|
|
const expectedCore = [...resolved.skills].map(stem => `${stem}.md`).sort();
|
|
assert.deepStrictEqual(
|
|
[...files].sort(),
|
|
expectedCore,
|
|
'commandsDir should contain exactly core commands'
|
|
);
|
|
});
|
|
|
|
test('removes superseded files when profile shrinks', (t) => {
|
|
const { base, runtimeConfigDir, commandsDir } = createFixtureRuntime();
|
|
t.after(() => cleanup(base));
|
|
writeActiveProfile(runtimeConfigDir, 'standard');
|
|
writeSurface(runtimeConfigDir, {
|
|
baseProfile: 'standard',
|
|
disabledClusters: [],
|
|
explicitAdds: [],
|
|
explicitRemoves: [],
|
|
});
|
|
const manifest = loadSkillsManifest(REAL_COMMANDS_DIR);
|
|
const layout = resolveRuntimeArtifactLayout('claude', runtimeConfigDir, 'local');
|
|
applySurface(runtimeConfigDir, layout, manifest, CLUSTERS);
|
|
|
|
const afterStandard = new Set(fs.readdirSync(commandsDir).filter(f => f.endsWith('.md')));
|
|
|
|
writeSurface(runtimeConfigDir, {
|
|
baseProfile: 'core',
|
|
disabledClusters: [],
|
|
explicitAdds: [],
|
|
explicitRemoves: [],
|
|
});
|
|
const resolvedCore = applySurface(runtimeConfigDir, layout, manifest, CLUSTERS);
|
|
|
|
const afterCore = new Set(fs.readdirSync(commandsDir).filter(f => f.endsWith('.md')));
|
|
|
|
assert.ok(afterCore.size <= afterStandard.size, 'core should have fewer or equal files than standard');
|
|
|
|
const expectedCore = [...resolvedCore.skills].map(stem => `${stem}.md`).sort();
|
|
assert.deepStrictEqual(
|
|
[...afterCore].sort(),
|
|
expectedCore,
|
|
'afterCore should contain exactly core commands'
|
|
);
|
|
|
|
for (const file of afterCore) {
|
|
assert.ok(
|
|
fs.existsSync(path.join(REAL_COMMANDS_DIR, file)),
|
|
`file in commandsDir not a real skill: ${file}`
|
|
);
|
|
}
|
|
});
|
|
|
|
test('leaves non-gsd .md files alone in agentsDir', (t) => {
|
|
const { base, runtimeConfigDir, agentsDir } = createFixtureRuntime();
|
|
t.after(() => cleanup(base));
|
|
const foreignAgent = path.join(agentsDir, 'my-custom-agent.md');
|
|
fs.writeFileSync(foreignAgent, '# custom agent\n', 'utf8');
|
|
|
|
writeActiveProfile(runtimeConfigDir, 'core');
|
|
writeSurface(runtimeConfigDir, {
|
|
baseProfile: 'core',
|
|
disabledClusters: [],
|
|
explicitAdds: [],
|
|
explicitRemoves: [],
|
|
});
|
|
const manifest = loadSkillsManifest(REAL_COMMANDS_DIR);
|
|
const layout = resolveRuntimeArtifactLayout('claude', runtimeConfigDir, 'local');
|
|
applySurface(runtimeConfigDir, layout, manifest, CLUSTERS);
|
|
|
|
assert.ok(fs.existsSync(foreignAgent), 'non-gsd agent file should not be touched');
|
|
});
|
|
|
|
test('adds missing skill files from install source', (t) => {
|
|
const { base, runtimeConfigDir, commandsDir } = createFixtureRuntime();
|
|
t.after(() => cleanup(base));
|
|
writeActiveProfile(runtimeConfigDir, 'core');
|
|
writeSurface(runtimeConfigDir, {
|
|
baseProfile: 'core',
|
|
disabledClusters: [],
|
|
explicitAdds: [],
|
|
explicitRemoves: [],
|
|
});
|
|
const manifest = loadSkillsManifest(REAL_COMMANDS_DIR);
|
|
const layout = resolveRuntimeArtifactLayout('claude', runtimeConfigDir, 'local');
|
|
applySurface(runtimeConfigDir, layout, manifest, CLUSTERS);
|
|
|
|
assert.ok(
|
|
fs.existsSync(path.join(commandsDir, 'help.md')),
|
|
'help.md should be copied from install source'
|
|
);
|
|
assert.ok(
|
|
fs.existsSync(path.join(commandsDir, 'new-project.md')),
|
|
'new-project.md should be copied from install source'
|
|
);
|
|
});
|
|
|
|
test('_syncGsdDir skills kind: adds missing skill dirs, removes stale prefix-matched dirs, preserves foreign dirs', (t) => {
|
|
const { _syncGsdDir } = require('../gsd-core/bin/lib/surface.cjs');
|
|
|
|
const base = createTempDir('gsd-surface-skills-');
|
|
t.after(() => cleanup(base));
|
|
const stagedDir = path.join(base, 'staged');
|
|
const destDir = path.join(base, 'dest');
|
|
fs.mkdirSync(destDir, { recursive: true });
|
|
|
|
const stem1 = 'gsd-help';
|
|
const stem2 = 'gsd-update';
|
|
fs.mkdirSync(path.join(stagedDir, stem1), { recursive: true });
|
|
fs.writeFileSync(path.join(stagedDir, stem1, 'SKILL.md'), '# help\n', 'utf8');
|
|
fs.mkdirSync(path.join(stagedDir, stem2), { recursive: true });
|
|
fs.writeFileSync(path.join(stagedDir, stem2, 'SKILL.md'), '# update\n', 'utf8');
|
|
|
|
const staleDir = path.join(destDir, 'gsd-old-skill');
|
|
fs.mkdirSync(staleDir, { recursive: true });
|
|
fs.writeFileSync(path.join(staleDir, 'SKILL.md'), '# old\n', 'utf8');
|
|
|
|
const foreignDir = path.join(destDir, 'my-custom-skill');
|
|
fs.mkdirSync(foreignDir, { recursive: true });
|
|
fs.writeFileSync(path.join(foreignDir, 'SKILL.md'), '# custom\n', 'utf8');
|
|
|
|
const skillsKind = { kind: 'skills', destSubpath: 'skills', prefix: 'gsd-', stage: () => stagedDir };
|
|
|
|
// Build a minimal manifest that includes the GSD-owned stems so that the
|
|
// manifest-membership gate (Finding 1 fix) correctly identifies gsd-old-skill
|
|
// as GSD-owned and prunes it. Without a manifest the new code conservatively
|
|
// preserves all gsd-* dirs it cannot confirm are GSD-owned.
|
|
const manifest = new Map([
|
|
['help', []],
|
|
['update', []],
|
|
['old-skill', []], // GSD-owned stale stem — must be pruned when not in staged set
|
|
]);
|
|
|
|
_syncGsdDir(stagedDir, destDir, skillsKind, manifest);
|
|
|
|
assert.ok(fs.existsSync(path.join(destDir, stem1, 'SKILL.md')), 'gsd-help/SKILL.md should be copied');
|
|
assert.ok(fs.existsSync(path.join(destDir, stem2, 'SKILL.md')), 'gsd-update/SKILL.md should be copied');
|
|
// stale gsd- dir removed (it's in the manifest so it is GSD-owned, but not in staged set)
|
|
assert.ok(!fs.existsSync(staleDir), 'stale gsd-old-skill dir should be removed');
|
|
assert.ok(fs.existsSync(foreignDir), 'my-custom-skill dir should be preserved');
|
|
});
|
|
|
|
test('applySurface recreates missing destination directories', (t) => {
|
|
const base = createTempDir('gsd-surface-missing-dest-');
|
|
t.after(() => cleanup(base));
|
|
const runtimeConfigDir = base;
|
|
writeActiveProfile(runtimeConfigDir, 'core');
|
|
writeSurface(runtimeConfigDir, {
|
|
baseProfile: 'core',
|
|
disabledClusters: [],
|
|
explicitAdds: [],
|
|
explicitRemoves: [],
|
|
});
|
|
const manifest = loadSkillsManifest(REAL_COMMANDS_DIR);
|
|
const layout = resolveRuntimeArtifactLayout('claude', runtimeConfigDir, 'local');
|
|
applySurface(runtimeConfigDir, layout, manifest, CLUSTERS);
|
|
|
|
const commandsDir = path.join(runtimeConfigDir, 'commands', 'gsd');
|
|
assert.ok(fs.existsSync(commandsDir), 'commands/gsd dir should be created even if initially absent');
|
|
const files = fs.readdirSync(commandsDir).filter(f => f.endsWith('.md'));
|
|
assert.ok(files.length > 0, 'commands/gsd should contain staged skill files');
|
|
assert.ok(files.includes('help.md'), 'help.md should be present after applySurface on missing dest');
|
|
});
|
|
|
|
test('Hermes profile shrink: stale GSD skill dirs are removed; user skills preserved', (t) => {
|
|
const { _syncGsdDir } = require('../gsd-core/bin/lib/surface.cjs');
|
|
|
|
const base = createTempDir('gsd-surface-hermes-shrink-');
|
|
t.after(() => cleanup(base));
|
|
const stagedDir = path.join(base, 'staged');
|
|
const destDir = path.join(base, 'dest');
|
|
fs.mkdirSync(destDir, { recursive: true });
|
|
|
|
fs.mkdirSync(path.join(stagedDir, 'gsd-executor'), { recursive: true });
|
|
fs.writeFileSync(path.join(stagedDir, 'gsd-executor', 'SKILL.md'), '# executor\n', 'utf8');
|
|
|
|
fs.mkdirSync(path.join(destDir, 'gsd-executor'), { recursive: true });
|
|
fs.writeFileSync(path.join(destDir, 'gsd-executor', 'SKILL.md'), '# executor\n', 'utf8');
|
|
fs.mkdirSync(path.join(destDir, 'gsd-planner'), { recursive: true });
|
|
fs.writeFileSync(path.join(destDir, 'gsd-planner', 'SKILL.md'), '# planner\n', 'utf8');
|
|
fs.mkdirSync(path.join(destDir, 'user-skill'), { recursive: true });
|
|
fs.writeFileSync(path.join(destDir, 'user-skill', 'SKILL.md'), '# user\n', 'utf8');
|
|
|
|
const manifest = new Map([
|
|
['gsd-executor', []],
|
|
['gsd-planner', []],
|
|
]);
|
|
|
|
const hermesKind = { kind: 'skills', destSubpath: 'skills/gsd', prefix: '', stage: () => stagedDir };
|
|
_syncGsdDir(stagedDir, destDir, hermesKind, manifest);
|
|
|
|
assert.ok(
|
|
fs.existsSync(path.join(destDir, 'gsd-executor', 'SKILL.md')),
|
|
'gsd-executor should be kept (in staged set)'
|
|
);
|
|
assert.ok(
|
|
!fs.existsSync(path.join(destDir, 'gsd-planner')),
|
|
'gsd-planner should be removed (in manifest but not in staged set — stale GSD skill)'
|
|
);
|
|
assert.ok(
|
|
fs.existsSync(path.join(destDir, 'user-skill', 'SKILL.md')),
|
|
'user-skill should be preserved (not in manifest — user-owned)'
|
|
);
|
|
});
|
|
|
|
test('_syncGsdDir skills kind (hermes): preserves non-GSD user dir under skills/gsd/ when kindPrefix is empty', (t) => {
|
|
const { _syncGsdDir } = require('../gsd-core/bin/lib/surface.cjs');
|
|
|
|
const base = createTempDir('gsd-surface-hermes-');
|
|
t.after(() => cleanup(base));
|
|
const stagedDir = path.join(base, 'staged');
|
|
const destDir = path.join(base, 'dest');
|
|
fs.mkdirSync(destDir, { recursive: true });
|
|
|
|
const stem1 = 'help';
|
|
fs.mkdirSync(path.join(stagedDir, stem1), { recursive: true });
|
|
fs.writeFileSync(path.join(stagedDir, stem1, 'SKILL.md'), '# help\n', 'utf8');
|
|
|
|
const userDir = path.join(destDir, 'user-custom-skill');
|
|
fs.mkdirSync(userDir, { recursive: true });
|
|
fs.writeFileSync(path.join(userDir, 'SKILL.md'), '# user custom\n', 'utf8');
|
|
|
|
const hermesKind = { kind: 'skills', destSubpath: 'skills/gsd', prefix: '', stage: () => stagedDir };
|
|
_syncGsdDir(stagedDir, destDir, hermesKind);
|
|
|
|
assert.ok(fs.existsSync(userDir), 'user-custom-skill dir must be preserved when kindPrefix is empty (Hermes)');
|
|
assert.ok(fs.existsSync(path.join(destDir, stem1, 'SKILL.md')), 'GSD help/SKILL.md must be copied');
|
|
});
|
|
});
|
|
|
|
// ─── resolveSurface ──────────────────────────────────────────────────────────
|
|
|
|
describe('resolveSurface', () => {
|
|
test('no surface state + core base profile → identical to resolveProfile core', () => {
|
|
const dir = tmpDir('gsd-surface-resolve-');
|
|
try {
|
|
writeActiveProfile(dir, 'core');
|
|
const manifest = realManifest();
|
|
const surfaceResolved = resolveSurface(dir, manifest, CLUSTERS);
|
|
const profileResolved = resolveProfile({ modes: ['core'], manifest });
|
|
|
|
assert.ok(surfaceResolved.skills instanceof Set);
|
|
assert.ok(profileResolved.skills instanceof Set);
|
|
assert.deepStrictEqual(
|
|
[...surfaceResolved.skills].sort(),
|
|
[...profileResolved.skills].sort(),
|
|
'surface with no state should equal profile resolution'
|
|
);
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
|
|
test('standard base + disabledClusters:["utility"] removes utility skills', () => {
|
|
const dir = tmpDir('gsd-surface-resolve-');
|
|
try {
|
|
writeActiveProfile(dir, 'standard');
|
|
writeSurface(dir, {
|
|
baseProfile: 'standard',
|
|
disabledClusters: ['utility'],
|
|
explicitAdds: [],
|
|
explicitRemoves: [],
|
|
});
|
|
const manifest = realManifest();
|
|
const resolved = resolveSurface(dir, manifest, CLUSTERS);
|
|
|
|
assert.ok(resolved.skills instanceof Set);
|
|
for (const stem of CLUSTERS.utility) {
|
|
const standardResolved = resolveProfile({ modes: ['standard'], manifest });
|
|
if (standardResolved.skills.has(stem)) {
|
|
assert.ok(
|
|
!resolved.skills.has(stem),
|
|
`"${stem}" should be removed by disabling utility cluster`
|
|
);
|
|
}
|
|
}
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
|
|
test('explicitAdds:["sketch"] adds sketch to a core install', () => {
|
|
const dir = tmpDir('gsd-surface-resolve-');
|
|
try {
|
|
writeActiveProfile(dir, 'core');
|
|
writeSurface(dir, {
|
|
baseProfile: 'core',
|
|
disabledClusters: [],
|
|
explicitAdds: ['sketch'],
|
|
explicitRemoves: [],
|
|
});
|
|
const manifest = realManifest();
|
|
const resolved = resolveSurface(dir, manifest, CLUSTERS);
|
|
|
|
assert.ok(resolved.skills instanceof Set);
|
|
assert.ok(resolved.skills.has('sketch'), 'sketch must be in resolved skills');
|
|
|
|
const sketchRequires = manifest.get('sketch') || [];
|
|
for (const dep of sketchRequires) {
|
|
assert.ok(resolved.skills.has(dep), `transitive dep "${dep}" of sketch must be present`);
|
|
}
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
|
|
test('explicitRemoves removes individual skill stems', () => {
|
|
const dir = tmpDir('gsd-surface-resolve-');
|
|
try {
|
|
writeSurface(dir, {
|
|
baseProfile: 'standard',
|
|
disabledClusters: [],
|
|
explicitAdds: [],
|
|
explicitRemoves: ['progress'],
|
|
});
|
|
writeActiveProfile(dir, 'standard');
|
|
const manifest = realManifest();
|
|
const resolved = resolveSurface(dir, manifest, CLUSTERS);
|
|
|
|
assert.ok(!resolved.skills.has('progress'), '"progress" must be removed by explicitRemoves');
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
|
|
test('result is a Set<string> with name property and agents Set', () => {
|
|
const dir = tmpDir('gsd-surface-resolve-');
|
|
try {
|
|
writeActiveProfile(dir, 'core');
|
|
const manifest = realManifest();
|
|
const resolved = resolveSurface(dir, manifest, CLUSTERS);
|
|
|
|
assert.ok(resolved.skills instanceof Set);
|
|
assert.ok(typeof resolved.name === 'string');
|
|
assert.ok(resolved.agents instanceof Set);
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
|
|
test('surface with baseProfile overrides .gsd-profile marker', () => {
|
|
const dir = tmpDir('gsd-surface-resolve-');
|
|
try {
|
|
writeActiveProfile(dir, 'core');
|
|
writeSurface(dir, {
|
|
baseProfile: 'standard',
|
|
disabledClusters: [],
|
|
explicitAdds: [],
|
|
explicitRemoves: [],
|
|
});
|
|
const manifest = realManifest();
|
|
const resolved = resolveSurface(dir, manifest, CLUSTERS);
|
|
const standardResolved = resolveProfile({ modes: ['standard'], manifest });
|
|
|
|
assert.deepStrictEqual(
|
|
[...resolved.skills].sort(),
|
|
[...standardResolved.skills].sort(),
|
|
'surface baseProfile takes precedence over marker'
|
|
);
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
|
|
test('disabled cluster + explicitAdds can re-add specific skills from disabled cluster', () => {
|
|
const dir = tmpDir('gsd-surface-resolve-');
|
|
try {
|
|
writeSurface(dir, {
|
|
baseProfile: 'standard',
|
|
disabledClusters: ['workspace_state'],
|
|
explicitAdds: ['capture'],
|
|
explicitRemoves: [],
|
|
});
|
|
writeActiveProfile(dir, 'standard');
|
|
const manifest = realManifest();
|
|
const resolved = resolveSurface(dir, manifest, CLUSTERS);
|
|
|
|
assert.ok(resolved.skills.has('capture'), '"capture" must be present via explicitAdds');
|
|
const standardResolved = resolveProfile({ modes: ['standard'], manifest });
|
|
for (const stem of CLUSTERS.workspace_state) {
|
|
if (stem === 'capture') continue;
|
|
if (standardResolved.skills.has(stem)) {
|
|
assert.ok(
|
|
!resolved.skills.has(stem),
|
|
`"${stem}" should be removed (workspace_state disabled, not explicitly re-added)`
|
|
);
|
|
}
|
|
}
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── readSurface / writeSurface ──────────────────────────────────────────────
|
|
|
|
describe('readSurface / writeSurface', () => {
|
|
test('round-trips a complete surface state', () => {
|
|
const dir = tmpDir('gsd-surface-state-');
|
|
try {
|
|
const state = {
|
|
baseProfile: 'standard',
|
|
disabledClusters: ['utility'],
|
|
explicitAdds: ['sketch'],
|
|
explicitRemoves: [],
|
|
};
|
|
writeSurface(dir, state);
|
|
const read = readSurface(dir);
|
|
assert.deepStrictEqual(read, state);
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
|
|
test('round-trips empty arrays', () => {
|
|
const dir = tmpDir('gsd-surface-state-');
|
|
try {
|
|
const state = {
|
|
baseProfile: 'core',
|
|
disabledClusters: [],
|
|
explicitAdds: [],
|
|
explicitRemoves: [],
|
|
};
|
|
writeSurface(dir, state);
|
|
assert.deepStrictEqual(readSurface(dir), state);
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
|
|
test('round-trips composed base profile', () => {
|
|
const dir = tmpDir('gsd-surface-state-');
|
|
try {
|
|
const state = {
|
|
baseProfile: 'core,audit',
|
|
disabledClusters: [],
|
|
explicitAdds: [],
|
|
explicitRemoves: ['health'],
|
|
};
|
|
writeSurface(dir, state);
|
|
assert.deepStrictEqual(readSurface(dir), state);
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
|
|
test('missing file returns null', () => {
|
|
const dir = tmpDir('gsd-surface-state-');
|
|
try {
|
|
const result = readSurface(dir);
|
|
assert.strictEqual(result, null);
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
|
|
test('non-existent directory returns null', () => {
|
|
const ghost = path.join(os.tmpdir(), 'gsd-surface-no-exist-' + Date.now());
|
|
const result = readSurface(ghost);
|
|
assert.strictEqual(result, null);
|
|
});
|
|
|
|
test('corrupt JSON returns null', () => {
|
|
const dir = tmpDir('gsd-surface-state-');
|
|
try {
|
|
fs.writeFileSync(path.join(dir, '.gsd-surface.json'), '{not valid json', 'utf8');
|
|
const result = readSurface(dir);
|
|
assert.strictEqual(result, null);
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
|
|
test('JSON missing baseProfile field returns null', () => {
|
|
const dir = tmpDir('gsd-surface-state-');
|
|
try {
|
|
fs.writeFileSync(
|
|
path.join(dir, '.gsd-surface.json'),
|
|
JSON.stringify({ disabledClusters: [], explicitAdds: [], explicitRemoves: [] }),
|
|
'utf8'
|
|
);
|
|
const result = readSurface(dir);
|
|
assert.strictEqual(result, null);
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
|
|
test('JSON with non-array disabledClusters returns null', () => {
|
|
const dir = tmpDir('gsd-surface-state-');
|
|
try {
|
|
fs.writeFileSync(
|
|
path.join(dir, '.gsd-surface.json'),
|
|
JSON.stringify({ baseProfile: 'standard', disabledClusters: 'utility', explicitAdds: [], explicitRemoves: [] }),
|
|
'utf8'
|
|
);
|
|
const result = readSurface(dir);
|
|
assert.strictEqual(result, null);
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
|
|
test('atomic write: result file is never a partial tmp file', () => {
|
|
const dir = tmpDir('gsd-surface-state-');
|
|
try {
|
|
const state = { baseProfile: 'full', disabledClusters: [], explicitAdds: [], explicitRemoves: [] };
|
|
writeSurface(dir, state);
|
|
const files = fs.readdirSync(dir);
|
|
const tmpFiles = files.filter(f => f.includes('.tmp.'));
|
|
assert.deepStrictEqual(tmpFiles, [], 'no tmp files should remain after write');
|
|
assert.ok(files.includes('.gsd-surface.json'));
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
|
|
test('second write overwrites first', () => {
|
|
const dir = tmpDir('gsd-surface-state-');
|
|
try {
|
|
writeSurface(dir, { baseProfile: 'core', disabledClusters: [], explicitAdds: [], explicitRemoves: [] });
|
|
writeSurface(dir, { baseProfile: 'standard', disabledClusters: ['utility'], explicitAdds: [], explicitRemoves: [] });
|
|
const read = readSurface(dir);
|
|
assert.strictEqual(read.baseProfile, 'standard');
|
|
assert.deepStrictEqual(read.disabledClusters, ['utility']);
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
|
|
test('writeSurface creates directory if it does not exist', () => {
|
|
const base = tmpDir('gsd-surface-state-');
|
|
const nested = path.join(base, 'skills', 'subdir');
|
|
try {
|
|
writeSurface(nested, { baseProfile: 'full', disabledClusters: [], explicitAdds: [], explicitRemoves: [] });
|
|
assert.ok(fs.existsSync(nested));
|
|
assert.ok(readSurface(nested) !== null);
|
|
} finally {
|
|
cleanup(base);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── CLUSTERS data structure ─────────────────────────────────────────────────
|
|
|
|
describe('CLUSTERS data structure', () => {
|
|
test('no cluster is empty', () => {
|
|
for (const [name, members] of Object.entries(CLUSTERS)) {
|
|
assert.ok(members.length > 0, `cluster ${name} must not be empty`);
|
|
}
|
|
});
|
|
|
|
test('every cluster member is a real skill stem in commands/gsd/', () => {
|
|
const entries = fs.readdirSync(REAL_COMMANDS_DIR, { withFileTypes: true });
|
|
const realStems = new Set(
|
|
entries
|
|
.filter(e => e.isFile() && e.name.endsWith('.md'))
|
|
.map(e => e.name.slice(0, -3))
|
|
);
|
|
const mismatches = [];
|
|
for (const [cluster, members] of Object.entries(CLUSTERS)) {
|
|
for (const stem of members) {
|
|
if (!realStems.has(stem)) {
|
|
mismatches.push(`${cluster}: "${stem}" not found in commands/gsd/`);
|
|
}
|
|
}
|
|
}
|
|
assert.deepStrictEqual(mismatches, [], `Cluster members missing from disk:\n${mismatches.join('\n')}`);
|
|
});
|
|
|
|
test('union of all clusters covers every skill in commands/gsd/', () => {
|
|
const entries = fs.readdirSync(REAL_COMMANDS_DIR, { withFileTypes: true });
|
|
const realStems = new Set(
|
|
entries
|
|
.filter(e => e.isFile() && e.name.endsWith('.md'))
|
|
.map(e => e.name.slice(0, -3))
|
|
);
|
|
const clustered = allClusteredSkills();
|
|
const uncategorized = [];
|
|
for (const stem of realStems) {
|
|
if (!clustered.has(stem)) uncategorized.push(stem);
|
|
}
|
|
assert.deepStrictEqual(
|
|
uncategorized,
|
|
[],
|
|
`Uncategorized skills (not in any cluster):\n${uncategorized.sort().join('\n')}`
|
|
);
|
|
});
|
|
|
|
test('CLUSTERS is frozen (immutable)', () => {
|
|
assert.ok(Object.isFrozen(CLUSTERS), 'CLUSTERS must be frozen');
|
|
for (const [name, members] of Object.entries(CLUSTERS)) {
|
|
assert.ok(Object.isFrozen(members), `CLUSTERS.${name} must be frozen`);
|
|
}
|
|
});
|
|
|
|
test('cluster names contain the expected set from research memo §3.2', () => {
|
|
const expectedClusterNames = new Set([
|
|
'core_loop',
|
|
'audit_review',
|
|
'milestone',
|
|
'research_ideate',
|
|
'workspace_state',
|
|
'docs',
|
|
'ui',
|
|
'ai_eval',
|
|
'ns_meta',
|
|
'utility',
|
|
]);
|
|
const actualClusterNames = new Set(Object.keys(CLUSTERS));
|
|
for (const name of expectedClusterNames) {
|
|
assert.ok(actualClusterNames.has(name), `expected cluster "${name}" missing from CLUSTERS`);
|
|
}
|
|
});
|
|
|
|
test('allClusteredSkills returns a Set containing all cluster members', () => {
|
|
const result = allClusteredSkills();
|
|
assert.ok(result instanceof Set, 'allClusteredSkills() must return a Set');
|
|
for (const members of Object.values(CLUSTERS)) {
|
|
for (const stem of members) {
|
|
assert.ok(result.has(stem), `allClusteredSkills() missing "${stem}"`);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── listSurface ─────────────────────────────────────────────────────────────
|
|
|
|
describe('listSurface', () => {
|
|
test('accepts parsed gsd-file-manifest JSON objects without crashing (#322)', () => {
|
|
const dir = tmpDir('gsd-surface-list-');
|
|
try {
|
|
fs.writeFileSync(path.join(dir, '.gsd-source'), REAL_COMMANDS_DIR, 'utf8');
|
|
writeActiveProfile(dir, 'core');
|
|
const diskManifestShape = {
|
|
version: '1.0.0',
|
|
timestamp: new Date().toISOString(),
|
|
mode: 'core',
|
|
files: {},
|
|
};
|
|
const result = listSurface(dir, diskManifestShape, CLUSTERS);
|
|
|
|
assert.ok(Array.isArray(result.enabled), 'enabled must be array');
|
|
assert.ok(Array.isArray(result.disabled), 'disabled must be array');
|
|
assert.ok(typeof result.tokenCost === 'number', 'tokenCost must be number');
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
|
|
test('returns { enabled, disabled, tokenCost } structure', () => {
|
|
const dir = tmpDir('gsd-surface-list-');
|
|
try {
|
|
fs.writeFileSync(path.join(dir, '.gsd-source'), REAL_COMMANDS_DIR, 'utf8');
|
|
writeActiveProfile(dir, 'core');
|
|
const manifest = loadSkillsManifest(REAL_COMMANDS_DIR);
|
|
const result = listSurface(dir, manifest, CLUSTERS);
|
|
|
|
assert.ok(Array.isArray(result.enabled), 'enabled must be array');
|
|
assert.ok(Array.isArray(result.disabled), 'disabled must be array');
|
|
assert.ok(typeof result.tokenCost === 'number', 'tokenCost must be number');
|
|
assert.ok(result.tokenCost >= 0, 'tokenCost must be non-negative');
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
|
|
test('core profile: enabled has fewer skills than full; enabled + disabled = total stems', () => {
|
|
const dir = tmpDir('gsd-surface-list-');
|
|
try {
|
|
fs.writeFileSync(path.join(dir, '.gsd-source'), REAL_COMMANDS_DIR, 'utf8');
|
|
writeActiveProfile(dir, 'core');
|
|
const manifest = loadSkillsManifest(REAL_COMMANDS_DIR);
|
|
const coreList = listSurface(dir, manifest, CLUSTERS);
|
|
|
|
const totalStems = [...manifest.keys()].filter(k => !k.startsWith('_calls_agents_')).length;
|
|
assert.ok(
|
|
coreList.enabled.length < totalStems,
|
|
'core should enable fewer skills than total'
|
|
);
|
|
assert.ok(coreList.disabled.length > 0, 'core should have some disabled skills');
|
|
assert.ok(coreList.enabled.length + coreList.disabled.length === totalStems,
|
|
'enabled + disabled must equal total stems');
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
|
|
test('disabling utility cluster reduces enabled count', () => {
|
|
const dir = tmpDir('gsd-surface-list-');
|
|
try {
|
|
fs.writeFileSync(path.join(dir, '.gsd-source'), REAL_COMMANDS_DIR, 'utf8');
|
|
writeActiveProfile(dir, 'standard');
|
|
const manifest = loadSkillsManifest(REAL_COMMANDS_DIR);
|
|
|
|
const beforeList = listSurface(dir, manifest, CLUSTERS);
|
|
|
|
writeSurface(dir, {
|
|
baseProfile: 'standard',
|
|
disabledClusters: ['utility'],
|
|
explicitAdds: [],
|
|
explicitRemoves: [],
|
|
});
|
|
const afterList = listSurface(dir, manifest, CLUSTERS);
|
|
|
|
assert.ok(afterList.enabled.length <= beforeList.enabled.length,
|
|
'disabling utility cluster should not increase enabled count');
|
|
assert.ok(afterList.tokenCost <= beforeList.tokenCost,
|
|
'disabling a cluster should not increase token cost');
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
|
|
test('tokenCost is sum of description char lengths ÷ 4 for enabled skills', () => {
|
|
const dir = tmpDir('gsd-surface-list-');
|
|
try {
|
|
fs.writeFileSync(path.join(dir, '.gsd-source'), REAL_COMMANDS_DIR, 'utf8');
|
|
writeActiveProfile(dir, 'core');
|
|
const manifest = loadSkillsManifest(REAL_COMMANDS_DIR);
|
|
const result = listSurface(dir, manifest, CLUSTERS);
|
|
|
|
let expected = 0;
|
|
for (const stem of result.enabled) {
|
|
const filePath = path.join(REAL_COMMANDS_DIR, `${stem}.md`);
|
|
if (!fs.existsSync(filePath)) continue;
|
|
const markdown = fs.readFileSync(filePath, 'utf8');
|
|
const description = readFrontmatterDescription(markdown);
|
|
if (description) expected += Math.ceil(description.length / 4);
|
|
}
|
|
|
|
assert.strictEqual(result.tokenCost, expected, 'tokenCost must equal sum of description lengths ÷ 4');
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
|
|
test('enabled and disabled arrays are sorted', () => {
|
|
const dir = tmpDir('gsd-surface-list-');
|
|
try {
|
|
fs.writeFileSync(path.join(dir, '.gsd-source'), REAL_COMMANDS_DIR, 'utf8');
|
|
writeActiveProfile(dir, 'standard');
|
|
const manifest = loadSkillsManifest(REAL_COMMANDS_DIR);
|
|
const result = listSurface(dir, manifest, CLUSTERS);
|
|
|
|
assert.deepStrictEqual(result.enabled, [...result.enabled].sort());
|
|
assert.deepStrictEqual(result.disabled, [...result.disabled].sort());
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
});
|