Files
get-shit-done/tests/context-utilization.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

123 lines
4.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/**
* Pure classifier for the gsd-health --context guard.
*
* Thresholds:
* < 60% healthy
* 6070% 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}`);
});
});