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`.
This commit is contained in:
Tom Boucher
2026-04-30 01:04:41 -04:00
committed by GitHub
parent c72b893916
commit 5fdc950eb7
18 changed files with 840 additions and 11 deletions

View File

@@ -0,0 +1,122 @@
'use strict';
/**
* Pure classifier for the gsd-health --context guard.
*
* Thresholds:
* < 60% healthy
* 60–70% warning
* ≥ 70% critical (fracture point)
*
* The classifier is a pure (tokensUsed, contextWindow) → { percent, state }
* function. Recommendation copy is owned by the SDK renderer (see
* tests/validate-context.test.cjs), not this module.
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const { classifyContextUtilization, STATES } = require('../get-shit-done/bin/lib/context-utilization.cjs');
describe('STATES constant exposes the three boundary names', () => {
test('exports HEALTHY, WARNING, CRITICAL', () => {
assert.deepStrictEqual(
[STATES.HEALTHY, STATES.WARNING, STATES.CRITICAL],
['healthy', 'warning', 'critical'],
);
});
});
describe('classifyContextUtilization — state thresholds', () => {
test('0 tokens used → healthy at 0%', () => {
const r = classifyContextUtilization(0, 200_000);
assert.strictEqual(r.percent, 0);
assert.strictEqual(r.state, STATES.HEALTHY);
});
test('just under 60% → healthy (state uses exact ratio, not rounded percent)', () => {
// 119_999 / 200_000 = 59.9995% — rounds to 60 for display, healthy by ratio.
const r = classifyContextUtilization(119_999, 200_000);
assert.strictEqual(r.state, STATES.HEALTHY);
});
test('exactly 60% → warning (inclusive lower bound)', () => {
const r = classifyContextUtilization(120_000, 200_000);
assert.strictEqual(r.percent, 60);
assert.strictEqual(r.state, STATES.WARNING);
});
test('between 60% and 70% → warning', () => {
const r = classifyContextUtilization(130_000, 200_000);
assert.strictEqual(r.percent, 65);
assert.strictEqual(r.state, STATES.WARNING);
});
test('just under 70% → warning', () => {
// 139_999 / 200_000 = 69.9995% — rounds to 70 for display, warning by ratio.
const r = classifyContextUtilization(139_999, 200_000);
assert.strictEqual(r.state, STATES.WARNING);
});
test('exactly 70% → critical (fracture point, inclusive lower bound)', () => {
const r = classifyContextUtilization(140_000, 200_000);
assert.strictEqual(r.percent, 70);
assert.strictEqual(r.state, STATES.CRITICAL);
});
test('above 70% → critical', () => {
const r = classifyContextUtilization(180_000, 200_000);
assert.strictEqual(r.percent, 90);
assert.strictEqual(r.state, STATES.CRITICAL);
});
test('tokensUsed >= contextWindow clamps to 100%', () => {
const r = classifyContextUtilization(250_000, 200_000);
assert.strictEqual(r.percent, 100);
assert.strictEqual(r.state, STATES.CRITICAL);
});
});
describe('classifyContextUtilization — return shape', () => {
test('result is exactly { percent, state } — no recommendation field', () => {
// Recommendation copy lives in the renderer, not the classifier.
// Keeping this contract narrow lets the prose evolve without
// re-validating the math layer.
const r = classifyContextUtilization(100_000, 200_000);
assert.deepStrictEqual(Object.keys(r).sort(), ['percent', 'state']);
});
});
describe('classifyContextUtilization — input validation', () => {
test('negative tokensUsed throws', () => {
assert.throws(() => classifyContextUtilization(-1, 200_000), /tokensUsed/);
});
test('non-integer tokensUsed throws', () => {
assert.throws(() => classifyContextUtilization(1.5, 200_000), /tokensUsed/);
});
test('zero contextWindow throws', () => {
assert.throws(() => classifyContextUtilization(100, 0), /contextWindow/);
});
test('negative contextWindow throws', () => {
assert.throws(() => classifyContextUtilization(100, -1), /contextWindow/);
});
test('non-number inputs throw via Number.isInteger', () => {
assert.throws(() => classifyContextUtilization('100', 200_000), /tokensUsed/);
assert.throws(() => classifyContextUtilization(100, '200000'), /contextWindow/);
assert.throws(() => classifyContextUtilization(NaN, 200_000), /tokensUsed/);
assert.throws(() => classifyContextUtilization(100, Infinity), /contextWindow/);
});
});
describe('classifyContextUtilization — percent rounding', () => {
test('display percent rounds; state uses exact ratio', () => {
// 119_998 / 200_000 = 59.999% — display rounds to 60, state stays healthy.
const r = classifyContextUtilization(119_998, 200_000);
assert.strictEqual(r.state, STATES.HEALTHY);
assert.ok([59, 60].includes(r.percent), `expected percent ∈ {59,60}, got ${r.percent}`);
});
});