diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 11da75d..3f8e5f7 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -32,6 +32,10 @@ jobs: run: git diff --check "$(git hash-object -t tree /dev/null)" HEAD - name: Validate provider catalogs run: node scripts/validate-provider-catalogs.mjs + - name: Verify generated agent skill against its canonical source + run: | + node --test scripts/sync-agent-skill.test.mjs + npm run skills:check - name: Install JSON Schema validation dependencies run: npm ci --ignore-scripts --no-audit - name: Validate machine-readable output schema diff --git a/AGENTS.md b/AGENTS.md index f810f27..f9283e4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,5 +21,7 @@ Write repository content, code comments, commit messages, issues, and pull reque ## Delivery +- Agent guidance is generated from the pinned `stack-sh/docs` source. Edit shared instructions there, then update `skills/docs-source.json` and run `npm run skills:sync`. Never hand-edit the generated skill; verify with `npm run skills:check` and the CLI integration tests. + - Use a topic branch and pull request; squash merge after approval. - Work in small increments and add repository-specific formatting, linting, tests, and release checks with the code that needs them. diff --git a/README.md b/README.md index 180d594..2a98359 100644 --- a/README.md +++ b/README.md @@ -107,8 +107,13 @@ for usage and the [skill source](./skills/stack-diagrams/SKILL.md) for review. To pin reviewed instructions, check out a specific commit of this repository and run `npx skills add /absolute/path/to/cli --skill stack-diagrams`. -The CLI repository owns these instructions; the website owns the usage guides. -Process-level tests execute the skill's command examples against the built CLI. +The shared instruction source lives in [stack-sh/docs](https://github.com/stack-sh/docs). +This repository distributes its generated skill; do not edit `SKILL.md` directly. +`skills/docs-source.json` pins the reviewed Docs commit and manifest SHA-256. +After merging a Docs change, update that lock and run `npm run skills:sync`. +`npm run skills:check` verifies the manifest, artifact hash, and exact local bytes; +CI rejects drift. Process-level tests also execute the generated commands against +the built CLI. Updating the skill does not require a new CLI binary release. ## Development diff --git a/package.json b/package.json index 7861baa..e5009e2 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,8 @@ "private": true, "type": "module", "scripts": { + "skills:check": "node scripts/sync-agent-skill.mjs --check", + "skills:sync": "node scripts/sync-agent-skill.mjs --sync", "test:cli-output-schema": "node scripts/validate-cli-output-schema.mjs && node --test scripts/cli-output-schema.test.mjs", "test:consumer-prototype": "node examples/consume-cli-json.mjs check tests/fixtures/render.stack" }, diff --git a/scripts/sync-agent-skill.mjs b/scripts/sync-agent-skill.mjs new file mode 100644 index 0000000..696262b --- /dev/null +++ b/scripts/sync-agent-skill.mjs @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = fileURLToPath(new URL('../', import.meta.url)); +const skillPath = 'skills/stack-diagrams/SKILL.md'; +const digest = bytes => createHash('sha256').update(bytes).digest('hex'); + +export async function fetchSkill(lock, fetchResource = fetch) { + assert.equal(lock.repository, 'stack-sh/docs'); + assert.match(lock.revision, /^[a-f0-9]{40}$/); + assert.match(lock.manifestSha256, /^[a-f0-9]{64}$/); + const base = `https://raw.githubusercontent.com/${lock.repository}/${lock.revision}/generated/`; + const get = async file => { + const response = await fetchResource(base + file, { signal: AbortSignal.timeout(15_000) }); + assert.ok(response.ok, `Docs resource unavailable: ${file} (HTTP ${response.status})`); + const bytes = Buffer.from(await response.arrayBuffer()); + assert.ok(bytes.length <= 1_048_576, 'Docs resource exceeds size limit'); + return bytes; + }; + const manifestBytes = await get('manifest.json'); + assert.equal(digest(manifestBytes), lock.manifestSha256, 'Docs manifest integrity mismatch'); + const manifest = JSON.parse(manifestBytes.toString('utf8')); + assert.equal(manifest.schemaVersion, '1.0', 'Unsupported Docs manifest version'); + assert.ok(Array.isArray(manifest.files)); + const entries = manifest.files.filter(entry => entry.path === skillPath); + assert.equal(entries.length, 1, 'Expected exactly one skill artifact'); + assert.match(entries[0].sha256, /^[a-f0-9]{64}$/); + const bytes = await get(skillPath); + assert.equal(digest(bytes), entries[0].sha256, 'Docs skill integrity mismatch'); + return bytes; +} + +export async function syncSkill(directory = root, write = false, fetchResource = fetch) { + const lock = JSON.parse(await readFile(path.join(directory, 'skills/docs-source.json'), 'utf8')); + const bytes = await fetchSkill(lock, fetchResource); + const target = path.join(directory, skillPath); + if (write) await writeFile(target, bytes); + else assert.deepEqual(await readFile(target), bytes, 'Generated skill drift: update Docs source, then run npm run skills:sync'); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + assert.ok(process.argv.slice(2).every(arg => ['--check', '--sync'].includes(arg))); + assert.ok(!(process.argv.includes('--check') && process.argv.includes('--sync'))); + await syncSkill(root, process.argv.includes('--sync')); +} diff --git a/scripts/sync-agent-skill.test.mjs b/scripts/sync-agent-skill.test.mjs new file mode 100644 index 0000000..16ce286 --- /dev/null +++ b/scripts/sync-agent-skill.test.mjs @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { fetchSkill, syncSkill } from './sync-agent-skill.mjs'; + +const digest = text => createHash('sha256').update(text).digest('hex'); +const content = 'canonical skill\n'; +function fixture(schemaVersion = '1.0') { + const manifest = JSON.stringify({ schemaVersion, files: [{ path: 'skills/stack-diagrams/SKILL.md', sha256: digest(content) }] }); + const lock = { repository: 'stack-sh/docs', revision: 'a'.repeat(40), manifestSha256: digest(manifest) }; + const fetchResource = async url => { + assert.ok(url.startsWith(`https://raw.githubusercontent.com/stack-sh/docs/${lock.revision}/generated/`)); + return new Response(url.endsWith('manifest.json') ? manifest : content); + }; + return { lock, manifest, fetchResource }; +} + +test('fetches only immutable provider artifacts with verified hashes', async () => { + const { lock, fetchResource } = fixture(); + assert.equal((await fetchSkill(lock, fetchResource)).toString(), content); +}); + +test('rejects mutable refs, unsupported schemas, missing resources, and tampering', async () => { + const { lock, manifest, fetchResource } = fixture(); + await assert.rejects(fetchSkill({ ...lock, revision: 'main' }, fetchResource)); + await assert.rejects(fetchSkill(lock, async () => new Response('', { status: 404 })), /HTTP 404/); + await assert.rejects(fetchSkill(lock, async () => new Response('altered')), /manifest integrity/); + await assert.rejects(fetchSkill(lock, async url => new Response(url.endsWith('manifest.json') ? manifest : 'altered')), /skill integrity/); + const newer = fixture('2.0'); + await assert.rejects(fetchSkill(newer.lock, newer.fetchResource), /Unsupported Docs manifest/); +}); + +test('check is read-only, rejects drift, and explicit sync restores canonical bytes', async t => { + const directory = await mkdtemp(path.join(os.tmpdir(), 'stack-cli-docs-source-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const { lock, fetchResource } = fixture(); + await mkdir(path.join(directory, 'skills/stack-diagrams'), { recursive: true }); + await writeFile(path.join(directory, 'skills/docs-source.json'), JSON.stringify(lock)); + const target = path.join(directory, 'skills/stack-diagrams/SKILL.md'); + await writeFile(target, 'manual change'); + await assert.rejects(syncSkill(directory, false, fetchResource), /Generated skill drift/); + assert.equal(await readFile(target, 'utf8'), 'manual change'); + await syncSkill(directory, true, fetchResource); + await syncSkill(directory, false, fetchResource); + assert.equal(await readFile(target, 'utf8'), content); +}); diff --git a/skills/docs-source.json b/skills/docs-source.json new file mode 100644 index 0000000..905d2b9 --- /dev/null +++ b/skills/docs-source.json @@ -0,0 +1,5 @@ +{ + "repository": "stack-sh/docs", + "revision": "69e4615c5356b44517fa557bea248ea2fe008631", + "manifestSha256": "3f84b30a4fb6216c96b13b53035bf62003504eee686fd7e44867c711da0fcfea" +} diff --git a/skills/stack-diagrams/SKILL.md b/skills/stack-diagrams/SKILL.md index 5727171..e48f3ed 100644 --- a/skills/stack-diagrams/SKILL.md +++ b/skills/stack-diagrams/SKILL.md @@ -1,9 +1,11 @@ --- -name: stack-diagrams -description: Create or edit Stack (.stack) software architecture diagrams, validate them with the Stack CLI, and render SVG. Use for Stack diagrams or when a user chooses Stack for architecture documentation; not for infrastructure provisioning or unrelated programming stacks. -license: Apache-2.0 +name: "stack-diagrams" +description: "Create or edit Stack (.stack) software architecture diagrams, validate them with the Stack CLI, and render SVG. Use for Stack diagrams or when a user chooses Stack for architecture documentation; not for infrastructure provisioning or unrelated programming stacks." +license: "Apache-2.0" --- + + # Stack diagrams Deliver editable `.stack` source and, when rendering is available, an SVG. Preserve the requested architecture and existing unrelated content. Stack describes architecture; it does not provision resources or execute application code.