Files
msd-core/tests/enh-2790-skill-consolidation.test.cjs
Tom Boucher 5fdc950eb7 feat(#2792): namespace meta-skills + keyword-tag descriptions + context utilization guard (#2825)
* feat(#2792): namespace meta-skills retargeted at the post-#2790 surface

This branch is now based on #2790's HEAD (the consolidation PR) instead
of main, and every routing table targets the consolidated surface so a
user routed by a namespace meta-skill never lands at a deleted /
folded sub-skill.

Cross-PR inconsistencies the original PR #2825 carried (vs #2790):

  - ns-ideate routed to gsd-note / gsd-add-todo / gsd-add-backlog /
    gsd-plant-seed → all folded into gsd-capture by #2790. Now routes
    to gsd-capture (the parent picks the mode from the user's intent).
  - ns-context routed to gsd-scan and gsd-intel → folded into
    gsd-map-codebase --fast / --query by #2790. Now routes to those
    flag forms.
  - ns-manage routed all workspace intent to gsd-list-workspaces (a
    list-only entry) → CR also flagged the over-narrow target. #2790
    folds into gsd-workspace; routing now points there.
  - ns-workflow routed to gsd-research-phase → deleted outright by
    #2790. Removed.
  - ns-project routed to gsd-plan-milestone-gaps → deleted outright by
    #2790. Removed.
  - None of the namespaces previously surfaced #2790's new consolidated
    skills (gsd-capture, gsd-phase, gsd-config, gsd-workspace,
    gsd-progress). All five are now reachable through the routers.
  - extract_learnings → extract-learnings (canonicalized by #2858).

Defect fixes within the namespace skills:

  - Hyphen-form `name:` (gsd-workflow, …) per the canonical naming
    contract — the colon-form addressed CR's drift complaint.
  - `Skill` added to allowed-tools on every router. The body instructs
    "Invoke the matched skill directly using the Skill tool" — without
    Skill in the permission list the meta-skill cannot route at all.

New regression guard in tests/enh-2792-namespace-skills.test.cjs: every
gsd-* token in any namespace router's table column resolves to a
surviving commands/gsd/*.md file (or to a known consolidated parent for
flag-form targets like gsd-map-codebase --fast). This single test would
have caught every dead-end route the original PR shipped with.

Skill-count cap in tests/enh-2790-skill-consolidation.test.cjs now
filters out ns-*.md from its <= 63 cap. Namespace routers are
descriptor-only entries, not part of the consolidation surface that cap
is policing — they have their own contract in
tests/enh-2792-namespace-skills.test.cjs.

INVENTORY.md gains a "Namespace Meta-Skills" section with the 6 router
rows; INVENTORY-MANIFEST.json gains 6 entries; the headline count moves
59 → 65 to match.

Out of scope for this rebase: the gsd-health --context flag (PR #2825
advertised the contract but didn't implement it). That's a separate
feature concern and is left untouched here.

5908/5908 on `npm test`.

* feat(#2792): implement gsd-health --context utilization guard

The original PR #2825 advertised a `--context` flag on gsd-health with a
60%/70% utilization threshold table but never implemented the workflow
logic — CR caught it as a contract leak, the rebase deferred it. This
commit closes the gap with TDD red/green/refactor.

Math layer (pure):
  - get-shit-done/bin/lib/context-utilization.cjs
    classifyContextUtilization(tokensUsed, contextWindow) →
      { percent, state }
    State boundaries use the exact ratio:
      < 60% healthy / 60–70% warning / ≥ 70% critical (fracture point)
    Display percent rounded for humans. Throws TypeError on non-integer
    or out-of-range inputs.
  - STATES = Object.freeze({ HEALTHY, WARNING, CRITICAL }) exported
    so callers reference the names by symbol, not by literal string.

SDK CLI integration:
  - get-shit-done/bin/gsd-tools.cjs
    `validate context --tokens-used N --context-window M [--json]`
    routes to the classifier, owns the recommendation copy (the
    classifier intentionally does not — keeps the renderer free to
    evolve without touching the math layer or its tests), and uses
    core.output's rawValue path for the sync-flush guarantee.
  - sdk/src/query/validate.ts + sdk/src/query/index.ts
    TypeScript validateContext handler registered at 'validate.context'
    and 'validate context'. Mirrors the CJS classifier inline (15 lines
    of arithmetic; not worth a shared cross-language module).

User-facing wiring:
  - commands/gsd/health.md frontmatter advertises --context, body
    documents the three-state threshold table.
  - get-shit-done/workflows/health.md adds a `context_check` step
    that's reached only when --context is set. Step calls
    `gsd-sdk query validate.context` with self-reported tokensUsed and
    contextWindow, prints the SDK output verbatim, and ends. Includes
    a TEXT_MODE plain-text fallback for non-Claude runtimes per #2012.

Tests:
  - tests/context-utilization.test.cjs (17 tests) — pure-function
    contract: state thresholds at every boundary, percent rounding,
    input validation, return-shape (no recommendation field — that's
    the renderer's job).
  - tests/validate-context.test.cjs (9 tests) — SDK CLI plumbing:
    arg parsing errors, JSON vs human rendering, recommendation copy
    pinned per state.
  - tests/enh-2792-namespace-skills.test.cjs (4 new tests) — markdown
    contract: --context advertised in argument-hint, threshold table
    in command body, context_check step exists in workflow, step
    invokes gsd-sdk query validate.context with both flags.

Inventory bookkeeping:
  - docs/INVENTORY.md "CLI Modules" 31 → 32; new row for
    context-utilization.cjs.
  - docs/INVENTORY-MANIFEST.json mirror.

5939/5939 on `npm test`.
2026-04-30 01:04:41 -04:00

269 lines
9.5 KiB
JavaScript

// allow-test-rule: source-text-is-the-product
// commands/gsd/*.md files ARE what the runtime loads — testing their
// existence/non-existence tests the deployed skill surface contract.
'use strict';
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd');
/**
* Parse the YAML frontmatter from a skill .md file.
* Returns an object with the frontmatter fields as strings.
* Only handles simple scalar and array values needed by these tests.
*/
function parseFrontmatter(filePath) {
const raw = fs.readFileSync(filePath, 'utf8');
const lines = raw.split('\n');
if (lines[0].trim() !== '---') return {};
const endIdx = lines.indexOf('---', 1);
if (endIdx === -1) return {};
const fmLines = lines.slice(1, endIdx);
const result = {};
let currentKey = null;
for (const line of fmLines) {
const kvMatch = line.match(/^([a-zA-Z0-9_-]+):\s*(.*)/);
if (kvMatch) {
currentKey = kvMatch[1];
result[currentKey] = kvMatch[2].trim();
} else if (currentKey && line.match(/^\s+-\s+/)) {
// array item — append to existing string value so callers can check membership
const item = line.replace(/^\s+-\s+/, '').trim();
result[currentKey] = result[currentKey] ? [result[currentKey], item].join('\n') : item;
}
}
return result;
}
function skillPath(name) {
return path.join(COMMANDS_DIR, `${name}.md`);
}
// ---------------------------------------------------------------------------
// Group: New consolidated skills exist
// ---------------------------------------------------------------------------
describe('new consolidated skills exist', () => {
test('commands/gsd/capture.md exists', () => {
assert.ok(fs.existsSync(skillPath('capture')), 'capture.md does not exist');
});
test('capture.md has a name: field in frontmatter', () => {
const fm = parseFrontmatter(skillPath('capture'));
assert.ok(fm.name && fm.name.length > 0, 'capture.md missing name: in frontmatter');
});
test('commands/gsd/phase.md exists', () => {
assert.ok(fs.existsSync(skillPath('phase')), 'phase.md does not exist');
});
test('phase.md has a name: field in frontmatter', () => {
const fm = parseFrontmatter(skillPath('phase'));
assert.ok(fm.name && fm.name.length > 0, 'phase.md missing name: in frontmatter');
});
test('commands/gsd/config.md exists', () => {
assert.ok(fs.existsSync(skillPath('config')), 'config.md does not exist');
});
test('config.md has a name: field in frontmatter', () => {
const fm = parseFrontmatter(skillPath('config'));
assert.ok(fm.name && fm.name.length > 0, 'config.md missing name: in frontmatter');
});
test('commands/gsd/workspace.md exists', () => {
assert.ok(fs.existsSync(skillPath('workspace')), 'workspace.md does not exist');
});
test('workspace.md has a name: field in frontmatter', () => {
const fm = parseFrontmatter(skillPath('workspace'));
assert.ok(fm.name && fm.name.length > 0, 'workspace.md missing name: in frontmatter');
});
});
// ---------------------------------------------------------------------------
// Group: Absorbed skills are removed
// ---------------------------------------------------------------------------
describe('absorbed skills are removed', () => {
const absorbed = [
['add-todo', 'absorbed into capture.md'],
['note', 'absorbed into capture.md'],
['add-backlog', 'absorbed into capture.md'],
['plant-seed', 'absorbed into capture.md'],
['check-todos', 'absorbed into capture.md'],
['add-phase', 'absorbed into phase.md'],
['insert-phase', 'absorbed into phase.md'],
['remove-phase', 'absorbed into phase.md'],
['edit-phase', 'absorbed into phase.md'],
['settings-advanced', 'absorbed into config.md'],
['settings-integrations', 'absorbed into config.md'],
['set-profile', 'absorbed into config.md'],
['new-workspace', 'absorbed into workspace.md'],
['list-workspaces', 'absorbed into workspace.md'],
['remove-workspace', 'absorbed into workspace.md'],
['sync-skills', 'absorbed into update.md'],
['reapply-patches', 'absorbed into update.md'],
['sketch-wrap-up', 'absorbed into sketch.md'],
['spike-wrap-up', 'absorbed into spike.md'],
['scan', 'absorbed into map-codebase.md'],
['intel', 'absorbed into map-codebase.md'],
['code-review-fix', 'absorbed into code-review.md'],
['next', 'absorbed into progress.md'],
['do', 'absorbed into progress.md'],
];
for (const [name, reason] of absorbed) {
test(`commands/gsd/${name}.md does NOT exist (${reason})`, () => {
assert.ok(
!fs.existsSync(skillPath(name)),
[
`${name}.md still exists but should have been deleted`,
`(${reason})`,
].join(' '),
);
});
}
});
// ---------------------------------------------------------------------------
// Group: Outright deletions
// ---------------------------------------------------------------------------
describe('outright deleted dead skills are removed', () => {
const deleted = [
'join-discord',
'research-phase',
'session-report',
'from-gsd2',
'analyze-dependencies',
'list-phase-assumptions',
'plan-milestone-gaps',
];
for (const name of deleted) {
test(`commands/gsd/${name}.md does NOT exist`, () => {
assert.ok(
!fs.existsSync(skillPath(name)),
`${name}.md still exists but should have been deleted (outright dead skill)`,
);
});
}
});
// ---------------------------------------------------------------------------
// Group: Parent skills updated with new flags
// ---------------------------------------------------------------------------
describe('parent skills updated with new flags in argument-hint', () => {
test('update.md argument-hint contains --sync', () => {
const fm = parseFrontmatter(skillPath('update'));
assert.ok(
(fm['argument-hint'] || '').includes('--sync'),
[
'update.md argument-hint does not contain --sync',
'got: ' + (fm['argument-hint'] || '(none)'),
].join('. '),
);
});
test('update.md argument-hint contains --reapply', () => {
const fm = parseFrontmatter(skillPath('update'));
assert.ok(
(fm['argument-hint'] || '').includes('--reapply'),
[
'update.md argument-hint does not contain --reapply',
'got: ' + (fm['argument-hint'] || '(none)'),
].join('. '),
);
});
test('sketch.md argument-hint contains --wrap-up', () => {
const fm = parseFrontmatter(skillPath('sketch'));
assert.ok(
(fm['argument-hint'] || '').includes('--wrap-up'),
[
'sketch.md argument-hint does not contain --wrap-up',
'got: ' + (fm['argument-hint'] || '(none)'),
].join('. '),
);
});
test('spike.md argument-hint contains --wrap-up', () => {
const fm = parseFrontmatter(skillPath('spike'));
assert.ok(
(fm['argument-hint'] || '').includes('--wrap-up'),
[
'spike.md argument-hint does not contain --wrap-up',
'got: ' + (fm['argument-hint'] || '(none)'),
].join('. '),
);
});
test('map-codebase.md argument-hint contains --fast', () => {
const fm = parseFrontmatter(skillPath('map-codebase'));
assert.ok(
(fm['argument-hint'] || '').includes('--fast'),
[
'map-codebase.md argument-hint does not contain --fast',
'got: ' + (fm['argument-hint'] || '(none)'),
].join('. '),
);
});
test('code-review.md argument-hint contains --fix', () => {
const fm = parseFrontmatter(skillPath('code-review'));
assert.ok(
(fm['argument-hint'] || '').includes('--fix'),
[
'code-review.md argument-hint does not contain --fix',
'got: ' + (fm['argument-hint'] || '(none)'),
].join('. '),
);
});
test('progress.md argument-hint contains --do', () => {
const fm = parseFrontmatter(skillPath('progress'));
assert.ok(
(fm['argument-hint'] || '').includes('--do'),
[
'progress.md argument-hint does not contain --do',
'got: ' + (fm['argument-hint'] || '(none)'),
].join('. '),
);
});
});
// ---------------------------------------------------------------------------
// Group: settings.md is NOT deleted
// ---------------------------------------------------------------------------
describe('settings.md is kept (merged into config entry point or remains standalone)', () => {
test('commands/gsd/settings.md still exists', () => {
assert.ok(
fs.existsSync(skillPath('settings')),
'settings.md was deleted — it should be kept (or renamed to config.md, but not both missing)',
);
});
});
// ---------------------------------------------------------------------------
// Group: Skill count reduced
// ---------------------------------------------------------------------------
describe('skill count', () => {
test('total user-invocable files in commands/gsd/*.md is <= 63', () => {
// Exclude `ns-*.md` namespace meta-skills (#2792) from this cap.
// Those are descriptor-only routers selected first by the model and
// are not part of the consolidation surface this test tracks; their
// own contract is enforced by tests/enh-2792-namespace-skills.test.cjs.
const files = fs.readdirSync(COMMANDS_DIR)
.filter((f) => f.endsWith('.md') && !f.startsWith('ns-'));
assert.ok(
files.length <= 63,
[
`Expected <= 63 user-invocable skill files, found ${files.length}.`,
'Consolidation target is ~58.',
].join(' '),
);
});
});