feat(#2182): scaffold community capability + EoS registry (tests + stubbed core)
Adds the discoverability-registry surface for issue #2182: JSON-sourced capability/eos catalogs, a pure schema/vocab module (registry-schema.cjs) with the ADR-857 loop points + ADR-1239 axes, thin validate/gen CLIs, the registry-entry PR template, README spec, and CONTEXT.md glossary terms. The three pure functions (isValidGsdRange/validateEntries/renderMarkdown) are stubbed here so the comprehensive test suite fails first (red), per the feature-implementation red-first directive; the next commit implements them. Refs #2182 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
107
scripts/gen-registry.cjs
Normal file
107
scripts/gen-registry.cjs
Normal file
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* scripts/gen-registry.cjs — generates docs/registries/capability-registry.md
|
||||
* (and, once PR2 ships docs/registries/eos.json, docs/registries/eos-registry.md)
|
||||
* from the corresponding source JSON, via registry-schema.cjs#renderMarkdown.
|
||||
* Issue #2182.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/gen-registry.cjs # print rendered markdown(s) to stdout
|
||||
* node scripts/gen-registry.cjs --write # write the *-registry.md file(s)
|
||||
* node scripts/gen-registry.cjs --check # exit 1 if a committed *-registry.md is stale
|
||||
*
|
||||
* Root is resolved from process.cwd() (not __dirname) — mirrors
|
||||
* scripts/validate-registry.cjs so both are drivable as subprocesses against
|
||||
* isolated temp-fixture directories via `cwd`.
|
||||
*/
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
|
||||
const { renderMarkdown } = require('./registry-schema.cjs');
|
||||
|
||||
const SOURCES = [
|
||||
{ type: 'capability', jsonFile: 'capabilities.json', mdFile: 'capability-registry.md' },
|
||||
{ type: 'eos', jsonFile: 'eos.json', mdFile: 'eos-registry.md' },
|
||||
];
|
||||
|
||||
/**
|
||||
* The generator always writes LF; a Windows checkout (autocrlf) may present
|
||||
* committed files with CRLF. Normalize before comparing so `--check` only
|
||||
* fails on real content drift, not checkout-introduced line-ending noise.
|
||||
*
|
||||
* @param {string} content
|
||||
* @returns {string}
|
||||
*/
|
||||
function normalizeLineEndings(content) {
|
||||
return content.replace(/\r/g, '');
|
||||
}
|
||||
|
||||
function getRegistriesDir() {
|
||||
return path.join(process.cwd(), 'docs', 'registries');
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the markdown for a single registry type from its committed source
|
||||
* JSON. Returns null if the source JSON does not exist (e.g. eos.json before
|
||||
* PR2 ships) — callers treat that as "nothing to do" rather than an error.
|
||||
*
|
||||
* @param {'capability'|'eos'} type
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function renderFor(type) {
|
||||
const source = SOURCES.find((s) => s.type === type);
|
||||
if (!source) throw new Error(`gen-registry: unknown registry type "${type}"`);
|
||||
|
||||
const jsonPath = path.join(getRegistriesDir(), source.jsonFile);
|
||||
if (!fs.existsSync(jsonPath)) return null;
|
||||
|
||||
const entries = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
|
||||
return renderMarkdown(entries, { type, sourceFile: source.jsonFile });
|
||||
}
|
||||
|
||||
function main() {
|
||||
const [, , flag] = process.argv;
|
||||
const registriesDir = getRegistriesDir();
|
||||
let anyDrift = false;
|
||||
|
||||
for (const { type, mdFile } of SOURCES) {
|
||||
const rendered = renderFor(type);
|
||||
if (rendered === null) continue; // source JSON absent (eos.json before PR2)
|
||||
|
||||
const mdPath = path.join(registriesDir, mdFile);
|
||||
|
||||
if (flag === '--check') {
|
||||
if (!fs.existsSync(mdPath)) {
|
||||
process.stderr.write(`${mdFile} does not exist. Run:\n node scripts/gen-registry.cjs --write\n`);
|
||||
anyDrift = true;
|
||||
continue;
|
||||
}
|
||||
const committed = fs.readFileSync(mdPath, 'utf8');
|
||||
if (normalizeLineEndings(committed) !== normalizeLineEndings(rendered)) {
|
||||
process.stderr.write(`${mdFile} is stale. Run:\n node scripts/gen-registry.cjs --write\n`);
|
||||
anyDrift = true;
|
||||
}
|
||||
} else if (flag === '--write') {
|
||||
fs.mkdirSync(registriesDir, { recursive: true });
|
||||
fs.writeFileSync(mdPath, rendered);
|
||||
process.stdout.write(`Wrote ${mdPath}\n`);
|
||||
} else {
|
||||
process.stdout.write(rendered + '\n');
|
||||
}
|
||||
}
|
||||
|
||||
if (flag === '--check') {
|
||||
if (anyDrift) throw new ExitError(1, 'registry markdown is stale');
|
||||
process.stdout.write('docs/registries/*.md are up to date.\n');
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (require.main === module) runMain(main);
|
||||
|
||||
module.exports = { main, renderFor, SOURCES, normalizeLineEndings };
|
||||
177
scripts/registry-schema.cjs
Normal file
177
scripts/registry-schema.cjs
Normal file
@@ -0,0 +1,177 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* scripts/registry-schema.cjs — pure schema/vocab constants + validation +
|
||||
* markdown-generation logic for the two third-party discoverability catalogs
|
||||
* (issue #2182):
|
||||
*
|
||||
* - `docs/registries/capabilities.json` → "GSD Community Capability Registry"
|
||||
* - `docs/registries/eos.json` → "GSD EoS Registry" (PR2)
|
||||
*
|
||||
* The vocabulary constants below are ADDITIVE CONTRACTS that track the
|
||||
* runtime/ADR closed vocabularies they describe — they are a documentation-
|
||||
* registry-scoped mirror, not the runtime source of truth:
|
||||
*
|
||||
* - `LOOP_POINTS` mirrors ADR-857 "Loop Extension Points (the 12)"
|
||||
* (docs/adr/857-capability-system.md §"Loop Extension Points (the 12)").
|
||||
* The canonical runtime set lives in `src/loop-resolver.cts`
|
||||
* (`CANONICAL_POINTS` / `CANONICAL_POINTS_FALLBACK`, derived from
|
||||
* `loop-host-contract.cjs`) — changing that set requires updating this
|
||||
* list too, since a registry entry's `loopExtensionPoints` describes
|
||||
* which of those 12 points a third-party capability extends.
|
||||
* - `HOOK_KINDS` mirrors ADR-857 Decision 4 "three hook kinds": `step`
|
||||
* (runs as its own sequenced unit), `contribution` (injects into the
|
||||
* core step's prompt/context), `gate` (checks and optionally blocks).
|
||||
* - `INTERFACE_POINTS` mirrors ADR-1239 "The six interface points" (the
|
||||
* Host-Integration Interface integration surface): command/workflow
|
||||
* invocation, agent dispatch, model invocation, lifecycle hooks,
|
||||
* state+config IO, artifact surface.
|
||||
* - `PROFILES` mirrors ADR-1239 "Host-capability profiles (negotiation
|
||||
* baselines)": `programmatic-cli`, `declarative-cli`, `ide`.
|
||||
* - `AXES` mirrors ADR-1239 "the eight negotiated axes" (the negotiated
|
||||
* capability schema exchanged at `initialize`): `embeddingMode`,
|
||||
* `commandSurface`, `dispatch`, `modelMode`, `hookBus`, `stateIO`,
|
||||
* `transport`, `runtime`. Seven of the eight are closed enums here;
|
||||
* `dispatch` is ADR-1239's structured negotiated object
|
||||
* (`{ namedDispatch, nested, maxDepth, background, subagentToolkit }`) —
|
||||
* this registry accepts a free-form human summary string instead, so it
|
||||
* carries the `AXES_FREE_STRING` sentinel rather than an enum array.
|
||||
* - `CAPABILITY_REQUIRED` / `EOS_REQUIRED` mirror the required top-level
|
||||
* fields for each entry type, including `enginesGsd` (ADR-1244 D1
|
||||
* "Versioned capability manifest" — the `engines.gsd` semver-range gate,
|
||||
* modelled on VS Code's `engines.vscode`).
|
||||
*
|
||||
* This module is pure — no `fs`/`process`/child-process access — so tests
|
||||
* can `require()` it directly and assert on structured return values.
|
||||
* `scripts/validate-registry.cjs` and `scripts/gen-registry.cjs` are the thin
|
||||
* CLI wrappers that perform I/O around these functions.
|
||||
*/
|
||||
|
||||
// ─── ADR-857 "Loop Extension Points (the 12)" ────────────────────────────────
|
||||
const LOOP_POINTS = Object.freeze([
|
||||
'discuss:pre',
|
||||
'discuss:post',
|
||||
'plan:pre',
|
||||
'plan:post',
|
||||
'execute:pre',
|
||||
'execute:wave:pre',
|
||||
'execute:wave:post',
|
||||
'execute:post',
|
||||
'verify:pre',
|
||||
'verify:post',
|
||||
'ship:pre',
|
||||
'ship:post',
|
||||
]);
|
||||
|
||||
// ─── ADR-857 Decision 4 — three hook kinds ───────────────────────────────────
|
||||
const HOOK_KINDS = Object.freeze(['step', 'contribution', 'gate']);
|
||||
|
||||
// ─── ADR-1239 "The six interface points" ─────────────────────────────────────
|
||||
const INTERFACE_POINTS = Object.freeze(['command', 'dispatch', 'model', 'hooks', 'state', 'artifact']);
|
||||
|
||||
// ─── ADR-1239 "Host-capability profiles (negotiation baselines)" ────────────
|
||||
const PROFILES = Object.freeze(['programmatic-cli', 'declarative-cli', 'ide']);
|
||||
|
||||
// Sentinel marking an AXES entry as a free-form descriptive string rather than
|
||||
// a closed enum array. `Array.isArray(AXES_FREE_STRING)` is false, so callers
|
||||
// can branch on `Array.isArray(AXES[key])` vs `AXES[key] === AXES_FREE_STRING`
|
||||
// without risking confusion with a real enum value.
|
||||
const AXES_FREE_STRING = Symbol('registry-schema.AXES_FREE_STRING');
|
||||
|
||||
// ─── ADR-1239 "the eight negotiated axes" ────────────────────────────────────
|
||||
const AXES = Object.freeze({
|
||||
embeddingMode: Object.freeze(['imperative', 'declarative']),
|
||||
commandSurface: Object.freeze(['slash-file', 'slash-programmatic', 'slash-toml', 'palette', 'prose-only']),
|
||||
dispatch: AXES_FREE_STRING,
|
||||
modelMode: Object.freeze(['active', 'passive']),
|
||||
hookBus: Object.freeze(['host', 'engine', 'none']),
|
||||
stateIO: Object.freeze(['filesystem', 'sandboxed-storage', 'session-log-append']),
|
||||
transport: Object.freeze(['mcp', 'native-extension']),
|
||||
runtime: Object.freeze(['node', 'bun', 'sandboxed-web', 'python', 'go', 'rust', 'electron', 'other']),
|
||||
});
|
||||
|
||||
// ─── Required top-level fields ───────────────────────────────────────────────
|
||||
const CAPABILITY_REQUIRED = Object.freeze([
|
||||
'id',
|
||||
'name',
|
||||
'type',
|
||||
'repo',
|
||||
'description',
|
||||
'author',
|
||||
'license',
|
||||
'enginesGsd',
|
||||
'install',
|
||||
'uninstall',
|
||||
'interactions',
|
||||
'discussion',
|
||||
]);
|
||||
|
||||
const EOS_REQUIRED = Object.freeze([
|
||||
'id',
|
||||
'name',
|
||||
'type',
|
||||
'repo',
|
||||
'description',
|
||||
'author',
|
||||
'license',
|
||||
'enginesGsd',
|
||||
'install',
|
||||
'uninstall',
|
||||
'interactions',
|
||||
'discussion',
|
||||
'protocolVersion',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Validate the SHAPE of an `engines.gsd`-style semver range string (ADR-1244
|
||||
* D1). Self-contained — no `semver` dependency, modelled on the constraint
|
||||
* parsing in `scripts/check-env.cjs` (`satisfiesConstraint`), but this
|
||||
* function validates that the range is well-formed rather than comparing it
|
||||
* against a concrete version.
|
||||
*
|
||||
* @param {string} _range
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isValidGsdRange(_range) {
|
||||
return true;
|
||||
// TODO(commit-B): real implementation per DESIGN.md
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an array of registry entries against the closed schema for
|
||||
* `opts.type` ('capability' | 'eos').
|
||||
*
|
||||
* @param {object[]} _entries
|
||||
* @param {{type: 'capability'|'eos'}} _opts
|
||||
* @returns {{ok: boolean, errors: Array<{index: number, id?: string, field: string, reason: string}>}}
|
||||
*/
|
||||
function validateEntries(_entries, _opts) {
|
||||
return { ok: true, errors: [] };
|
||||
// TODO(commit-B): real implementation per DESIGN.md
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the deterministic Markdown document for a registry.
|
||||
*
|
||||
* @param {object[]} _entries
|
||||
* @param {{type: 'capability'|'eos', sourceFile?: string}} _opts
|
||||
* @returns {string}
|
||||
*/
|
||||
function renderMarkdown(_entries, _opts) {
|
||||
return '';
|
||||
// TODO(commit-B): real implementation per DESIGN.md
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
LOOP_POINTS,
|
||||
HOOK_KINDS,
|
||||
INTERFACE_POINTS,
|
||||
PROFILES,
|
||||
AXES,
|
||||
AXES_FREE_STRING,
|
||||
CAPABILITY_REQUIRED,
|
||||
EOS_REQUIRED,
|
||||
isValidGsdRange,
|
||||
validateEntries,
|
||||
renderMarkdown,
|
||||
};
|
||||
117
scripts/validate-registry.cjs
Normal file
117
scripts/validate-registry.cjs
Normal file
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* scripts/validate-registry.cjs — CLI validator for the third-party
|
||||
* discoverability catalogs (issue #2182):
|
||||
*
|
||||
* - docs/registries/capabilities.json ("GSD Community Capability Registry")
|
||||
* - docs/registries/eos.json ("GSD EoS Registry", PR2 — optional
|
||||
* until that JSON file ships)
|
||||
*
|
||||
* Validates each source's JSON array against the closed schema in
|
||||
* scripts/registry-schema.cjs (validateEntries). Human-readable errors go to
|
||||
* stderr; `--json` additionally prints a structured verdict to stdout.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/validate-registry.cjs # human-readable report
|
||||
* node scripts/validate-registry.cjs --json # structured JSON verdict
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 every present source's entries are all valid
|
||||
* 1 one or more entries failed validation (or a source's JSON is malformed)
|
||||
*/
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
|
||||
const { validateEntries } = require('./registry-schema.cjs');
|
||||
|
||||
// Resolved relative to process.cwd() (not __dirname) so the CLI validates
|
||||
// whichever project it is invoked from — this is what lets tests drive it as
|
||||
// a subprocess against isolated temp-fixture directories via `cwd`.
|
||||
const SOURCES = [
|
||||
{ file: 'capabilities.json', type: 'capability' },
|
||||
{ file: 'eos.json', type: 'eos' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Load + validate a single registry JSON file.
|
||||
*
|
||||
* @param {string} jsonPath absolute path to the registry JSON file
|
||||
* @param {'capability'|'eos'} type
|
||||
* @returns {{ok: boolean, errors: Array<{index: number, id?: string, field: string, reason: string}>}}
|
||||
*/
|
||||
function validateFile(jsonPath, type) {
|
||||
let raw;
|
||||
try {
|
||||
raw = fs.readFileSync(jsonPath, 'utf8');
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
errors: [{ index: -1, field: '<file>', reason: `could not read ${jsonPath}: ${err.message}` }],
|
||||
};
|
||||
}
|
||||
|
||||
let entries;
|
||||
try {
|
||||
entries = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
errors: [{ index: -1, field: '<file>', reason: `JSON parse error in ${jsonPath}: ${err.message}` }],
|
||||
};
|
||||
}
|
||||
|
||||
if (!Array.isArray(entries)) {
|
||||
return {
|
||||
ok: false,
|
||||
errors: [{ index: -1, field: '<file>', reason: `${jsonPath} must be a JSON array of entries` }],
|
||||
};
|
||||
}
|
||||
|
||||
return validateEntries(entries, { type });
|
||||
}
|
||||
|
||||
function main() {
|
||||
const jsonMode = process.argv.includes('--json');
|
||||
const registriesDir = path.join(process.cwd(), 'docs', 'registries');
|
||||
|
||||
const results = [];
|
||||
let anyFailed = false;
|
||||
|
||||
for (const { file, type } of SOURCES) {
|
||||
const jsonPath = path.join(registriesDir, file);
|
||||
// eos.json is optional until PR2 ships it — skip silently when absent.
|
||||
if (type === 'eos' && !fs.existsSync(jsonPath)) continue;
|
||||
|
||||
const verdict = validateFile(jsonPath, type);
|
||||
results.push({ file, type, ok: verdict.ok, errors: verdict.errors });
|
||||
if (!verdict.ok) anyFailed = true;
|
||||
}
|
||||
|
||||
if (jsonMode) {
|
||||
process.stdout.write(JSON.stringify({ ok: !anyFailed, results }, null, 2) + '\n');
|
||||
} else if (anyFailed) {
|
||||
process.stderr.write('\nERROR validate-registry: one or more entries failed validation\n');
|
||||
for (const result of results) {
|
||||
if (result.ok) continue;
|
||||
process.stderr.write(`\n${result.file}:\n`);
|
||||
for (const e of result.errors) {
|
||||
const idPart = e.id ? ` (id: ${e.id})` : '';
|
||||
process.stderr.write(` entry[${e.index}]${idPart} field "${e.field}": ${e.reason}\n`);
|
||||
}
|
||||
}
|
||||
process.stderr.write('\n');
|
||||
} else {
|
||||
process.stdout.write('ok validate-registry: all entries valid\n');
|
||||
}
|
||||
|
||||
if (anyFailed) throw new ExitError(1, 'registry validation failed');
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (require.main === module) runMain(main);
|
||||
|
||||
module.exports = { main, validateFile, SOURCES };
|
||||
Reference in New Issue
Block a user