Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion .agents/skills/stack/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,35 @@ cover at least:
- **Supply-chain floor**, as soon as the stack has dependencies: an SBOM (software bill of
materials) of at least top-level dependencies - the CRA legal floor - plus dependency
audit and license scan, all wired into CI with this ecosystem's current tools.
- **Design detector**, when the product has a user interface: a CI stage that runs the design
method's own detector over the surfaces this project ships, beside the typecheck and the
tests. It is deterministic, model-free and needs no key, so it belongs with the mechanical
gates rather than with the design skill. Give the stage a runner that meets the method's
stated `engines.node`, which can be higher than the one the rest of CI uses:

```yaml
- name: The shipped surfaces carry none of the tells this framework refuses
run: npx -y impeccable@latest detect <the paths this project ships>
```

No `continue-on-error` and no fallback: a detector that cannot install is a red job, because
a green tick standing for a scan that never ran is worse than no scan. `stack-gates` reads
this stage and counts the detector as wired only when a workflow actually runs it. The edit
hook needs nothing here: it is installed with the payload by
`node checks/design-method.mjs --install`, and it reports while the code is being written.
Run the CI command itself to reproduce a CI finding locally. Measured on 2026-08-07: the
detector bundled with the installed payload reports less than the published CLI on the same
file (9 findings against 0 on this repo's own page), so the hook's silence is not the gate's
verdict.
- Add this ecosystem's file extensions to `extraTextExtensions`/`extraCodeExtensions` in
`checks/config.json` so the denylist/secrets/zombie checks cover the product code.
- Add the chosen tools' commands to the stack standards file so any agent can run them.
- Add the chosen tools' commands to the stack standards file so any agent can run them. Where
the detector is wired, that file also says what it looks at (the tells a rendered interface
gives away: type, layout, color, motion, contrast, design-system drift) and how a false
positive is retired: the narrowest exception that fits, recorded with its reason in
`.impeccable/config.json` through the method's own `hooks ignore-value` command, never by
editing the config by hand and never by widening the rule off the whole project. A finding
nobody examined is not a false positive.
- Prove the gates work: introduce a deliberate violation, watch the gate fail, revert.
An untested gate is false confidence.

Expand Down
25 changes: 25 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,31 @@ jobs:
# - name: Secret scan (gitleaks or equivalent)
# - name: Build

# The first mechanical check this framework has ever had on what an interface renders
# (spec 011). The design method is installed per project and gitignored like a dependency, so
# this job fetches it at its current release instead of reading it out of the tree. A project
# on Groundwork with no interface deletes this job; one with more surfaces adds them to the
# scan. `stack-gates` reads this stage: while .impeccable/config.json declares the method, a
# workflow has to actually run the detector.
design:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
# The design method's own floor (its `engines.node`: 22.12 or newer at v4.0.4). The
# jobs beside it stay on 20; this one does not settle that question for them.
node-version: 22

# index.html is what this repository publishes. docs/design/reference/ stays out of the
# scan on purpose: each of its pages mimics one UI library's look so the owner can compare
# foundations, so a tell there is the file doing its job. Findings that were examined and
# accepted live with their reason in .impeccable/config.json, which this run reads.
# Nothing here falls back to green: a detector that cannot install fails the job, because
# a green tick standing for a scan that never ran is worse than no scan at all.
- name: The published page carries none of the tells this framework refuses
run: npx -y impeccable@latest detect index.html

# Every adoption claim Groundwork makes rests on one walk: a fresh copy reaches a governed
# first commit without the maintainer. Proving it once by hand dates the evidence; proving it
# on every push means the green tick beside a commit is the claim, and a broken copy route
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ Thumbs.db
CLAUDE.local.md
*.local.md
.claude/scheduled_tasks.lock
# Where the design method's edit hook is wired for this machine. It names a path inside the
# gitignored payload, so a shared copy of it would point every clone at a file it does not have.
.claude/settings.local.json
.idea/
.vscode/*
!.vscode/settings.json
Expand Down
44 changes: 44 additions & 0 deletions .impeccable/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
{
"detector": {
"ignoreRules": [],
"ignoreFiles": [],
"ignoreValues": [
{
"rule": "overused-font",
"value": "inter",
"files": [
"index.html"
],
"createdAt": "2026-08-07T19:36:56.195Z",
"reason": "Owner confirmed 2026-08-07: Inter is the explainer's body face by choice. The picker swaps the display face only, and both faces are self-hosted so the page fetches nothing."
},
{
"rule": "marquee",
"value": "*",
"files": [
"index.html"
],
"createdAt": "2026-08-07T19:36:56.224Z",
"reason": "Owner confirmed 2026-08-07: the tool band is decorative (aria-hidden), pauses on hover, and is disabled under reduced motion."
},
{
"rule": "cramped-padding",
"value": "*",
"files": [
"index.html"
],
"createdAt": "2026-08-07T19:52:47.881Z",
"reason": "Measured false positive 2026-08-07: the three hits are .stat cells whose padding-left:0 always travels with border-left:0 (.stat:first-child, and :nth-child(odd) plus :last-child below 760px). The static engine merges those selectors and reads a zero inset against a border that cell does not have. Reproduced in a five-line fixture, and absent as soon as the pairing is removed."
},
{
"rule": "flat-type-hierarchy",
"value": "*",
"files": [
"index.html"
],
"createdAt": "2026-08-07T19:52:47.911Z",
"reason": "Owner confirmed 2026-08-07: the rule wants roughly 1.25x between every step, measured on a 12.5/14/16 fixture that still fires, so passing it means running this page on two body sizes. It is dense UI (stat labels, code chips, captions, sheet hints) and that ramp would flatten those distinctions into body text."
}
]
}
}
51 changes: 36 additions & 15 deletions checks/check-stack.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,29 +20,50 @@ const stackFiles = (standards) => readdirSync(standards, { withFileTypes: true }
&& e.name !== 'GLOBAL.md' && !e.name.startsWith('TEMPLATE-'))
.map((e) => e.name);

// A line that runs the design detector, as opposed to one that talks about it. Comments are
// excluded on purpose: a commented stage is the exact state this gate exists to catch, and it is
// how a workflow claims a check it never performs.
const runsDetector = (line) => !/^\s*#/.test(line) && /impeccable/i.test(line) && /\bdetect\b/.test(line);

export const stackChecks = ({ root, fail, lines }) => ({
'stack-gates'() {
const standards = join(root, 'docs', 'standards');
if (!existsSync(standards)) return;
const stacks = stackFiles(standards);
if (!stacks.length) return;

// Another CI host is explicitly allowed (`stack` section 3: "or this host's equivalent"),
// and whether CI exists at all is enforcement.mjs's report to make. One fact, one place.
const wfDir = join(root, '.github', 'workflows');
if (!existsSync(wfDir)) return;
const workflows = readdirSync(wfDir).filter((n) => /\.ya?ml$/.test(n));

for (const name of readdirSync(wfDir).filter((n) => /\.ya?ml$/.test(n))) {
lines(join(wfDir, name)).forEach((line, i) => {
if (!/^\s*#\s*(-\s*name:|---\s*Stack gates)/.test(line)) return;
fail(`.github/workflows/${name}:${i + 1} still carries a commented-out stack gate while docs/standards/ names a stack (${stacks.join(', ')}). Until that stage is filled in, CI proves Groundwork's own rules and nothing about this project's code. Replace the placeholders with this stack's real gates per the skill \`stack\` section 3, and delete the ones this stack has no equivalent for instead of leaving them commented.`);
});
const standards = join(root, 'docs', 'standards');
const stacks = existsSync(standards) ? stackFiles(standards) : [];
if (stacks.length) {
for (const name of workflows) {
lines(join(wfDir, name)).forEach((line, i) => {
if (!/^\s*#\s*(-\s*name:|---\s*Stack gates)/.test(line)) return;
fail(`.github/workflows/${name}:${i + 1} still carries a commented-out stack gate while docs/standards/ names a stack (${stacks.join(', ')}). Until that stage is filled in, CI proves Groundwork's own rules and nothing about this project's code. Replace the placeholders with this stack's real gates per the skill \`stack\` section 3, and delete the ones this stack has no equivalent for instead of leaving them commented.`);
});
}
}

// The design method's half of the same window. The detector is the first mechanical check
// this framework has on what an interface renders (spec 011), and it is the one gate whose
// payload is deliberately absent from a clone: it is gitignored like a dependency. So the
// question "does this project have an interface it judges with the method" is answered by
// the tracked artifact the method writes, never by looking for the payload on disk.
if (!existsSync(join(root, '.impeccable', 'config.json'))) return;
const wired = workflows.some((name) => lines(join(wfDir, name)).some(runsDetector));
if (!wired) {
fail(`.impeccable/config.json declares the design method for this project, but no workflow in .github/workflows/ runs its detector, so nothing mechanical looks at what this interface renders. Add the stage per the skill \`stack\` section 3 (\`npx -y impeccable@latest detect <the surfaces this project ships>\`), and leave it running rather than commented: a stage nobody runs proves nothing.`);
}
},
});

// What this gate deliberately does not do: name the tools it expects to find. A list of blessed
// commands per ecosystem is the kind of allowance list that rots, and it would turn every new
// language into a change here. So the mechanical half is "the placeholders were dealt with",
// and proving the wired gates actually bite stays where `stack` section 3 already puts it:
// introduce a violation, watch the gate fail, revert.
// What this gate deliberately does not do: name the tools it expects to find per ecosystem. A
// list of blessed commands per language is the kind of allowance list that rots, and it would
// turn every new language into a change here. So the stack half is "the placeholders were dealt
// with", and proving the wired gates actually bite stays where `stack` section 3 already puts
// it: introduce a violation, watch the gate fail, revert.
//
// The design half names one tool, because there is one: the project chose impeccable as its
// design method (decision 0020), the same way it chose a stack. What it still does not name is
// which surfaces to scan or which flags to pass, so a project can widen or narrow its own scan
// without touching this file.
39 changes: 39 additions & 0 deletions checks/check-stack.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,43 @@ expectClean('stack-gates-armed', (fx) => {
// report to make. A project on GitLab must not be failed here for not being on GitHub.
expectClean('stack-gates-another-ci-host', stack);

// --- The design half: the detector counts as wired only when a workflow runs it -------------

const DESIGN_CONFIG = JSON.stringify({ detector: { ignoreRules: [], ignoreFiles: [], ignoreValues: [] } });
const design = ({ put }) => put('.impeccable/config.json', DESIGN_CONFIG);

const detectStep = ' - name: Design detector\n run: npx -y impeccable@latest detect index.html\n';

// A project that judges its interface with the design method, and a CI run that never looks at
// what that interface renders. This is the window the design half exists for.
expectFail('stack-gates', (fx) => {
design(fx);
fx.put('.github/workflows/ci.yml', ARMED_CI);
});

// The evidence rule, stated as a test: a stage that is only talked about is not a stage. Without
// this one the gate would accept the placeholder it was written to refuse.
expectFail('stack-gates', (fx) => {
design(fx);
fx.put('.github/workflows/ci.yml', `${ARMED_CI} # - name: Design detector\n # run: npx impeccable detect index.html\n`);
});

// Wired for real, and the gate goes quiet.
expectClean('stack-gates-detector-wired', (fx) => {
design(fx);
fx.put('.github/workflows/ci.yml', ARMED_CI + detectStep);
});

// A copy that has not stood up the design method has no config to declare one, and must not be
// failed for missing a scan of an interface it does not have.
expectClean('stack-gates-quiet-without-the-design-method', ({ put }) =>
put('.github/workflows/ci.yml', ARMED_CI));

// The two halves are independent: a stack file is not what arms the design half, and a wired
// detector does not excuse a workflow whose stack gates are still commented out.
expectClean('stack-gates-detector-alone-needs-no-stack-file', (fx) => {
design(fx);
fx.put('.github/workflows/ci.yml', PLACEHOLDER_CI + detectStep);
});

report('stack-gate');
11 changes: 8 additions & 3 deletions checks/design-method.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,13 @@
// The install lands in .claude/skills, which is a symlink into .agents/skills here (decision
// 0002), and upstream deliberately drops such a link so each harness gets its own build. So the
// route installs the Claude build, then puts the payload where our skills live and restores the
// link: the same files, reachable under both names, with the symlink gate still green. The
// detector hook is not wired here; `stack` owns that, beside the ecosystem's own gates.
// link: the same files, reachable under both names, with the symlink gate still green.
//
// The edit hook comes with the payload, because that is the half that has to be there while the
// code is being written; it is wired per machine in the gitignored .claude/settings.local.json,
// and its command is guarded so a clone without the payload is a no-op rather than an error. The
// other half, the detector as a CI stage, is `stack`'s to wire beside the ecosystem's own gates,
// where `stack-gates` can see whether a workflow really runs it.

import {
existsSync, readFileSync, renameSync, rmSync, symlinkSync, lstatSync, readdirSync, rmdirSync,
Expand Down Expand Up @@ -108,7 +113,7 @@ export function install(root) {
throw new Error(`Node ${process.versions.node} is below ${PACKAGE}'s requirement (${want.range}): upgrade Node first, nothing was written.`);
}
console.log(`Node ${process.versions.node} meets ${PACKAGE} ${want.range || '(no stated range)'}. Installing...`);
run('npx', ['-y', `${PACKAGE}@latest`, 'install', '--providers=claude', '--scope=project', '--yes', '--no-hooks'],
run('npx', ['-y', `${PACKAGE}@latest`, 'install', '--providers=claude', '--scope=project', '--yes'],
{ cwd: root, stdio: ['ignore', 'inherit', 'inherit'] });
const what = adopt(root);
const { version } = designMethod(root);
Expand Down
Loading