Compare commits

..

12 commits

Author SHA1 Message Date
45a2bf30de feat(engine): per-repo heading alias closes the Non-goals/nb-repo gap
The Non-goals contract is one literal English heading, matched
case-insensitively but never translated — a repo whose readers were
declared `nb` in `locales` could only go green by planting an English
heading inside an otherwise-Norwegian document. Measured on
ki-produktivitetsmodell (order, census 09): `## Virkeområde og
forbehold` already does the job Non-goals exists for, HEADING-MISSING
fired anyway. `heading_aliases` in the register is the same shape
`titles` already is for the README H1 — decision in the repo,
bookkeeping here — keyed per repo so two nb-repos need not share a
Norwegian phrasing. Satisfying a requirement through it is its own OK
(HEADING-ALIAS), never folded silently into the aggregate.

Also books two operator title decisions verified against the live
clones (not the coord messages that reported them): .profile's H1
became "From AI to Chitta — open" (org landing page rebuild, commit
9898a6e), and ki-produktivitetsmodell's H1 "Tre nivå av organisatorisk
produktivitet med KI" is deliberate, not drift.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gh6T6iHHkMvgeBbGaLmLQD
2026-08-20 23:14:05 +02:00
e061c1d219 docs(register): ki-produktivitetsmodell is nb — operator decision
README is written entirely in Norwegian, explicitly for "toppledere i norsk
offentlig sektor" — the same Norway-only-audience test that set okr's locale,
not a code trait. Clears the README-LANGUAGE WARN against the default `en`;
verified against the clone (13 passed vs. 12 before, WARN gone, the other
three findings there are that repo's own to fix — HEADING-MISSING,
README-H1, two real LINK-FILE-URL).

Operator approved on the session's recommendation; titles left open per the
same recommendation (lower priority, no specific title to record yet).
2026-08-18 17:44:30 +02:00
1ee6cc28c6 test(engine): pin inspectRepo's multi-file loading through the real I/O path
Closes a real gap the order (20260818T132719Z) correctly identified even
though its diagnosis was wrong: every LINK-FILE-URL test fed `files` to
checkInternalLinks by hand, so nothing pinned that inspectRepo's own loading
loop (`tracked.filter(p => p.endsWith('.md'))`) actually reaches files other
than README.md. A future narrowing of that filter back to README-only would
have kept all 247 tests green.

Two new tests go through inspectRepo itself (git init + git add in a temp
dir, no commit — avoids the gitleaks pre-commit hook and unnecessary git
config). Verified red-then-green: temporarily replaced the loading loop with
an empty one, confirmed the leak-detection test failed with the exact
"did not fire" message, reverted, confirmed all 249 pass.

This crosses the file's stated "pure classifiers only, inspectRepo is
exercised live" boundary deliberately and narrowly, for the one behaviour an
external report specifically asked to see pinned through the ordinary
loading path rather than by feeding `files` directly.
2026-08-18 17:11:58 +02:00
5fe1743105 docs(engine): record the file:// root-widening decision — order 20260818T132719Z
Answers the order's two asks, verified independently:

1. "sjekken leser bare README.md" — FALSE against the current codebase.
   inspectRepo has scanned every tracked .md file since 2026-07-27 (commit
   816ba97), three weeks before this order. Reproduced the order's exact
   scenario against a fresh clone of open/app-creator: CLAUDE.md:136 does not
   fire, but not from the diagnosed cause — the cited link uses the `/.../`
   elision this engine already treats as a documented placeholder convention
   (the same "18/22 split" the FILE_URL_LEAK comment already describes), not
   a leak. Injected a real (non-elided) file:// leak into the same CLAUDE.md
   in the same clone: LINK-FILE-URL fired immediately, correct file:line.
   No code change corresponds to this half of the order.

2. "bør regelen gjelde file://-lenker generelt, ikke bare /Users|home" — live,
   unmeasured, answered here. Grepped every tracked .md file in the 21 of 22
   registered repos with a local clone (llm-security-commons absent).
   Positive control: the two real /Users/ leaks in ki-produktivitetsmodell's
   CLAUDE.md were found, proving the query isn't silently empty. Result:
   zero occurrences of any other root (no C:\, /private/, /var/, /tmp/) in
   150 file:// hits across 12 repos — every other hit is a generic
   placeholder already declined by the Users|home anchor. Recorded as a
   RELEASE-ASSETS-shaped rejection in the code comment: no subject, not a
   low rate. Widen the day a real one appears.

Closing the order with --no-commit: this resolves both halves, it does not
hand anything back for the sender to act on.
2026-08-18 17:02:27 +02:00
aee0f28539 fix(engine): TAG-ANNOTATED never consulted the register for the newest tag
Reported by catalog (coord, 2026-08-17): register/repos.json has listed
ktg-plugin-marketplace v7.7.2 under tags_lightweight_accepted since the
0.11.2 release (2026-08-14), but `accepted` was only ever applied to
tags.slice(0, -1) — the newest tag is excluded from that slice by
construction, so the entry was dead weight from the day it was written.

checkTagIntegrity now checks the newest tag against `accepted` too, emitting
a distinct TAG-ANNOTATED-ACCEPTED-NEWEST OK instead of silently doing
nothing when the register names it exactly. Acceptance is per-tag-name, not
a standing exemption: a new lightweight tag that becomes newest afterwards
is still judged (tested).

TDD: two new tests written failing first (an existing test's title claimed
"cannot be accepted away" but never actually passed a matching `name`, so it
was accidentally still green either way — reworded to test what it actually
covers). Verified against a fresh clone of ktg-plugin-marketplace: the full
finding set now reads OK TAG-ANNOTATED-ACCEPTED-NEWEST + OK
TAG-ANNOTATED-ACCEPTED(7 older) instead of ERROR, with no knock-on effect on
TAG-SIGNED (all 9 tags predate the signing policy). Register comment updated
to record the three-day dead-weight window rather than silently correcting
it — a decision that turns out wrong is worse than no record.

247 tests pass (245 + 2 new).
2026-08-18 16:59:00 +02:00
4714f13959 docs(register): add ki-produktivitetsmodell as shared-asset
Reported unregistered by ki-leadership (REPO-UNREGISTERED SKIP). Verified
directly against the forge clone rather than trusting the report: no code,
no build chain, no install form — README + CHANGELOG + LICENSE (CC BY 4.0) +
a standalone HTML rendering + a PDF. Matches the other shared-asset entries
(playground-design-system, portfolio-optimiser-commons, llm-security-commons)
far better than standalone, which would demand a false `## Install` heading.

--refresh confirms this was the only drift: 22 registered, 23 on the forge,
exactly this one name missing.

First finding set against the clone: HEADING-MISSING (no `## Non-goals`),
README-H1 and README-LANGUAGE WARNs (title/locale — both operator calls per
this register's own convention, left unset here), and two real LINK-FILE-URL
ERRORs in CLAUDE.md that are not placeholders. That repo's own findings to
fix, not ours.
2026-08-18 16:54:05 +02:00
1a72f8dde5 fix(version): package.json drifted from the v0.11.2 tag it never got bumped to
Confirmed independently by ki-leadership's own gate run (cached engine, but
same root cause). v0.11.2 was tagged and released (f08fcc9) without bumping
package.json — no new tag or catalog ref changes here, just syncing the
version string to what was already published.
2026-08-18 16:53:57 +02:00
1b0a9a338e docs: add SECURITY.md
AAA+ program round 3 (C-axis, trust). No file existed before; new
vulnerability-reporting policy at repo root, matching the org-wide
10/10 template from the coordinated round.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VF8cJYs2vZazMisAd95nfM
2026-08-16 21:14:18 +02:00
198c95e5e5 docs: add Table of Contents to README
AAA+ B-axis round 2 (ORDRE 32). 311-line README with 6 H2 sections had
no navigation aid, matching the org-wide pattern (config-audit/README.md).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0187bUAyMzytxGyXcDQJkQKE
2026-08-16 16:18:12 +02:00
f08fcc9b80 chore(release): v0.11.2 — two register decisions and the stale prose counts
Register: ms-ai-architect removed from locales (org-ops D35 — the entry,
not the prose, was the wrong side); ktg-plugin-marketplace v7.7.2 accepted
as lightweight history (catalog's measured counter: monorepo-era tag, zero
consumers). README: the two stale prose counts made count-free.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzGk2pnq1N7J9H7xj2XAH1
2026-08-14 20:19:04 +02:00
e5cd1bfaaf fix(engine): FILE-MISSING named the class for a trait-sourced requirement
security -> SECURITY.md read as "missing required file for class
`standalone`", sending the operator to a class definition that never
listed the requirement. requirementsFor now carries each required
file's origin (class vs. trait) and checkRequiredFiles names whichever
actually required it. Failing test written first per the Iron Law.

No release forced by this alone.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WuWwACwhun35j52AnTxRKV
2026-08-13 18:51:21 +02:00
f422b63bb3 fix(engine): the affirmative TAGS-SIGNED was suppressed by its own exemption OK
Gated on `findings.length === 0`, so a TAG-SIGNED-PREPOLICY OK silenced it —
and every repo in the org has pre-policy tags, which made the affirmative
verdict near-unreachable in practice.

Caught by dogfooding v0.11.0: this repo signed the first tag the check ever
judged, and the gate did not say so. Status was green either way, which is
exactly why it was worth fixing — a reader could not tell "signed its new tag"
from "has cut no tag since the policy". Two different facts wearing one
silence, which is the defect this engine already refuses to ship one check
over, where an exemption nobody can see reads like a check that stopped
running.

Keys the affirmative on the JUDGED findings only. Measured after: 18 repos
emit both codes, 3 NONE, still 0 ERROR / 0 WARN. 243 tests, from 241.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsPsVsvhrSaejK3cLPmnN2
2026-08-13 10:54:35 +02:00
9 changed files with 465 additions and 45 deletions

View file

@ -1,6 +1,6 @@
{
"name": "repo-standard",
"version": "0.11.0",
"version": "0.11.2",
"description": "Per-repo gate for the open/ presentation standard: README first screen, install block, files required by the repo's class, and dead repo references.",
"author": {
"name": "Kjell Tore Guttormsen"

View file

@ -6,6 +6,59 @@ versioning is [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.11.2] — 2026-08-14
### Changed
- **Register: `ms-ai-architect` removed from `locales`.** Four sweeps in a row
(0407) held a correct `README-LANGUAGE` `WARN` against an entry that was
itself wrong: the repo's published README disavows a Norway-only audience in
its own words ("useful for any enterprise context", line 17, verified against
the forge), and its 24 Norway markers are traits — what the code does — not
audience. The register was the wrong side; the prose stands. Decided on
org-ops' measured case (their D35).
- **Register: `ktg-plugin-marketplace v7.7.2` accepted as lightweight
history.** Previously deliberately absent because the newest tag has the one
safe remedy. The catalog's measured counter: v7.7.2 is a monorepo-era
llm-security tag (2026-05-19, pre-split), not a catalog release, and zero
install paths consume it — force-moving a published ref for no consumer costs
more than the finding. The catalog committed to annotating every future tag,
which the check itself enforces.
- **README: the two prose counts that had gone stale are now count-free.**
"170 tests" (actual: 245) and "twelve checks" survived two releases
unchanged — the ungated copy of a number is the defect, so the copies are
removed rather than corrected. `npm test` prints the test count; the check
table (20 rows) is the canonical check list.
### Fixed
- **`FILE-MISSING` named the class even when a TRAIT required the file.**
`security``SECURITY.md` read as "missing required file for class
`standalone`", sending the operator looking for a requirement the class
never listed. `requirementsFor` now carries the source (`class` vs. `trait`)
alongside each required file, and the message names whichever actually
required it. 245 tests, from 243.
## [0.11.1] — 2026-08-13
### Fixed
- **`TAGS-SIGNED`, the affirmative verdict, was near-unreachable.** It was gated
on `findings.length === 0`, so the presence of a `TAG-SIGNED-PREPOLICY` `OK`
suppressed it — and every repo in the org has pre-policy tags. Caught by
dogfooding the v0.11.0 release: this repo signed the first tag the check ever
judged, and the gate did not say so.
The status was green either way, which is what made it worth fixing rather
than shrugging at: a reader could not tell *this repo signed its new tag* from
*this repo has cut no tag since the policy*. Two different facts wearing one
silence — the same defect as an exemption nobody can see, which this engine
already refuses to ship one check over. The gate now keys the affirmative on
the JUDGED findings only, and the two cases carry different sentences.
Measured after: 18 repos emit both `TAGS-SIGNED` and `TAG-SIGNED-PREPOLICY`,
3 `TAG-SIGNED-NONE`, still 0 ERROR and 0 WARN. 243 tests, from 241.
## [0.11.0] — 2026-08-13
### Added

View file

@ -225,6 +225,21 @@ would recreate, in data, exactly the drift this plugin exists to remove.
stands. What the gate must never do is make "we decided this" and "nobody
looked" the same output. The wanted side effect is exposure, not silence —
`ai-psychosis` is deliberately unregistered so it stands alone.
- **A fixed-language contract has a blind spot the `titles` pattern already
knows how to close.** `## Non-goals` is one literal English string, matched
case-insensitively but not translated, so it cannot be satisfied by a repo
whose readers were declared `nb` in `locales` — the only way to go green was
planting an English heading inside an otherwise-Norwegian document. Measured
on `ki-produktivitetsmodell` (order, 2026-08-18, census 09 on that repo):
`nb` locale, `## Virkeområde og forbehold` already doing the job Non-goals
exists for (a stranger sees what the repo does not try to be), `HEADING-
MISSING` `ERROR` regardless. `heading_aliases` in the register is the same
shape `titles` already is for the H1 — the decision is taken in the repo, the
bookkeeping happens here — and satisfying a requirement through it is its own
`OK` (`HEADING-ALIAS`), never folded silently into the aggregate. Keyed PER
REPO, not per locale: a locale-wide translation table would force every
nb-repo into the same Norwegian phrasing for the same section, exactly the
constraint `titles` already rejects for H1s one requirement over.
- **An exemption is a finding, not a deletion.** `readme_desc_match: false`
turns off README-DESC equality for a class, and the check still emits an `OK`
naming why. An exception nobody can see reads exactly like a check that
@ -344,7 +359,7 @@ would recreate, in data, exactly the drift this plugin exists to remove.
## Commands
```bash
npm test # 241 tests
npm test # 245 tests
node scripts/repo-standard-check.mjs --dir "$PWD" # gate one repo
node scripts/repo-standard-check.mjs --offline # no network call
node scripts/repo-standard-check.mjs --json # machine output

View file

@ -10,7 +10,7 @@ checks that surface in one repository and reports what it finds.
*AI-generated: all code produced by Claude Code through dialog-driven development.*
![Version](https://img.shields.io/badge/version-0.11.0-blue)
![Version](https://img.shields.io/badge/version-0.11.2-blue)
![Platform](https://img.shields.io/badge/platform-Claude_Code_Plugin-purple)
![Skills](https://img.shields.io/badge/skills-1-orange)
![License](https://img.shields.io/badge/license-MIT-lightgrey)
@ -21,6 +21,15 @@ has no Actions runner — so the test claim is one you run yourself, in one
command, from a clean clone: `npm test`. A badge asserting it would be a claim
dressed as evidence.
## Table of Contents
- [Install](#install)
- [Requirements](#requirements)
- [What it does](#what-it-does)
- [Non-goals](#non-goals)
- [Tests](#tests)
- [Changelog](#changelog)
## Install
Use the `https://` form. The forge UI's clone button hands out an `ssh://` URL,
@ -69,7 +78,7 @@ The repository's **class** decides what each check means:
| Install block | the form for this class is missing, incomplete, shown over `ssh://`, or points at the wrong marketplace |
| Install truth | the plugin is not pinned in the catalog, so the documented command cannot succeed for anyone |
| Install pins | a `@v…` or `--branch v…` in an install command names a tag the forge does not have, so the command a stranger copies 404s |
| Required headings | `## Install`, `## Non-goals`, `## Changelog` — per class. Present at the wrong depth is its own finding |
| Required headings | `## Install`, `## Non-goals`, `## Changelog` — per class. Present at the wrong depth is its own finding. A registered per-repo heading alias (`register/repos.json`'s `heading_aliases`) satisfies it too, for a repo whose readers were declared to speak another language |
| Required files | a file this class (or trait) needs is absent |
| Repo references | an `open/<name>` in URL position resolves to nothing |
| Relative links | a link points at a file that is not tracked |
@ -98,7 +107,7 @@ severity — it is the absence of a verdict, printed apart from the judged
findings.
A repository's own `status` is the worst of the findings that were *judged*, and
`SKIP` only when nothing was. A skip cannot outrank twelve checks that did run.
`SKIP` only when nothing was. A skip cannot outrank the checks that did run.
Coverage rides beside it on its own axis, and since 0.8.0 that axis has two
values, because a skip is one of two unrelated facts:
@ -180,6 +189,26 @@ An H1 that matches neither the repo name nor a registered title is still a
warning, and the message names both — that is drift in one of the two, not a
title.
### Registered heading aliases — the same asymmetry, one requirement over
`## Non-goals` (and every other required heading) is one fixed English string,
matched case-insensitively but never translated. A repository whose readers
were declared `nb` in the register's `locales` cannot satisfy it honestly —
the only way to go green was planting an English heading inside an otherwise
Norwegian document, which is worse than the warning it silences.
A `heading_aliases` entry in the register is the fix, shaped exactly like
`titles`: the decision — this section, in this repo's own language, does the
job the English heading names — is made in the repo, and the bookkeeping
happens here. Set one, and a README carrying the aliased heading instead of
the literal one is an `OK` (`HEADING-ALIAS`) that names the alias, never a
silent pass. Leave it out, and `HEADING-MISSING` stands exactly as before.
It is keyed **per repository**, not per locale. A locale-wide translation
table would force every `nb` repository into the same Norwegian phrasing for
the same section — precisely the constraint `titles` already rejects for H1s,
where a human title is a valid choice, not a slot filled from a fixed list.
### When equality is the wrong demand
`README-DESC` requires the README's opening line to be the forge description
@ -297,7 +326,9 @@ distinct from "an older engine that never tried".
npm test
```
170 tests over the pure classifiers. The reference fixtures are measured false
The suite pins the pure classifiers; `npm test` prints the current count, which
is the only copy of that number this README keeps — a prose count here went
stale twice. The reference fixtures are measured false
positives, each with its expected verdict — the six that produced the
three-outcome reference rule, plus the noise sources found by running the gate
against a real repository: regexes inside code spans that are markdown links to

34
SECURITY.md Normal file
View file

@ -0,0 +1,34 @@
# Security policy
## Reporting a vulnerability
Report privately to <security@fromaitochitta.com> — do not open a
public issue.
Canonical repository: https://git.fromaitochitta.com/open/repo-standard
Please include the affected version or commit, a minimal reproduction,
and the impact you see. We acknowledge every report within 5 working
days, agree a fix and disclosure timeline with the reporter, and aim to
disclose within 90 days of the initial report.
## Response process
1. Acknowledge within 5 working days.
2. Triage and confirm severity within 10 working days.
3. Develop and test a fix.
4. Publish an advisory and credit the reporter unless they prefer
to remain anonymous.
## Supported versions
| Version | Supported |
| ------- | --------- |
| 0.11.x | :white_check_mark: |
| < 0.11 | :x: |
This project has not reached 1.0 yet; only the latest 0.x release line
receives security fixes. See `CHANGELOG.md` for release history.
## Advisories
No advisories have been published yet.

View file

@ -1,6 +1,6 @@
{
"name": "repo-standard",
"version": "0.11.0",
"version": "0.11.2",
"private": true,
"type": "module",
"engines": {

View file

@ -32,6 +32,7 @@
"playground-design-system": "shared-asset",
"portfolio-optimiser-commons": "shared-asset",
"llm-security-commons": "shared-asset",
"ki-produktivitetsmodell": "shared-asset",
".profile": "org-profile",
"portfolio-optimiser": "standalone",
"portfolio-optimiser-claude": "standalone",
@ -134,14 +135,27 @@
"",
"`ai-psychosis` (`# Interaction Awareness`) is DELIBERATELY ABSENT: it is the",
"one where a reader cannot connect the title to the repo. Leaving it standing",
"alone is the wanted effect of registering the others."
"alone is the wanted effect of registering the others.",
"",
"`.profile` UPDATED 2026-08-18 (org-ops coord, census 09): the org landing",
"page was rebuilt the same day (commit 9898a6e, 'rebuild org landing page,",
"add LICENSE and SECURITY.md', per that repo's own STATE.md — order from",
"`.claude`, AAA+ round 5). The H1 became `# From AI to Chitta — open`; the",
"old value here was the H1 it replaced, not drift. Verified against the",
"clone, not the coord message — the em-dash is U+2014, not a hyphen.",
"",
"`ki-produktivitetsmodell` ADDED 2026-08-18 (order from that repo, on its own",
"operator decision recorded in its STATE.md the same day): `# Tre nivå av",
"organisatorisk produktivitet med KI` is the document's real title, the repo",
"name is a slug. Verified against the clone before writing it down."
],
"titles": {
".profile": "fromaitochitta / open",
".profile": "From AI to Chitta — open",
"llm-ingestion-pipeline-security": "llm-ingestion-guard",
"linkedin-studio": "LinkedIn Studio Plugin for Claude Code",
"llm-security": "LLM Security Plugin for Claude Code",
"ms-ai-architect": "AI Architect Plugin for Claude Code"
"ms-ai-architect": "AI Architect Plugin for Claude Code",
"ki-produktivitetsmodell": "Tre nivå av organisatorisk produktivitet med KI"
},
"$comment_traits": [
@ -175,15 +189,56 @@
"Norwegian readership is `nb`, and is then WRONG in English, not right.",
"",
"PROPOSED BY THE OPERATOR, NOT MEASURED — like `traits`, this list is a",
"judgement and no remote can report it. Both entries below were named by",
"judgement and no remote can report it. Both original entries were named by",
"the operator on 2026-08-04 as Norway-only in their audience.",
"",
"`ms-ai-architect` was REMOVED 2026-08-14 (org-ops coord, their D35): its",
"published README disavows the exclusivity this axis requires — line 17,",
"'while remaining useful for any enterprise context' — verified against the",
"forge before removal. Its 24 Norway markers are TRAITS (what the code",
"does: Digdir, NSM, DPIA), which is the axis confusion this comment warns",
"about two paragraphs up. The register was the wrong side, not the prose.",
"`okr` stands: its README IS Norwegian, rewritten to close census 05.",
"",
"`ki-produktivitetsmodell` ADDED 2026-08-18 (operator decision, on the",
"session's recommendation after registering the repo as `shared-asset`):",
"its README is written entirely in Norwegian, explicitly for 'toppledere i",
"norsk offentlig sektor' — the same Norway-only-audience test that set",
"`okr`, not a code trait.",
"",
"Detection is a stopword-frequency comparison over prose with code stripped.",
"It answers WHICH language dominates, never whether the prose is any good."
],
"locales": {
"ms-ai-architect": "nb",
"okr": "nb"
"okr": "nb",
"ki-produktivitetsmodell": "nb"
},
"$comment_heading_aliases": [
"The Non-goals contract is one fixed English string, matched literally — it",
"cannot be satisfied by a repo whose readers were declared `nb` in `locales`",
"above, and the only way to go green was to plant an English heading inside",
"an otherwise-Norwegian document. Same job `titles` does for a README H1, one",
"requirement over: the decision is taken in the repo, the bookkeeping happens",
"here — a registered alias satisfies the requirement and is its own OK",
"(`HEADING-ALIAS`), never a silent pass.",
"",
"Keyed PER REPO, not per locale: a locale-wide translation table would force",
"every nb-repo into the same Norwegian phrasing for the same section, which",
"is exactly the constraint `titles` already rejects for H1s (a human title is",
"a valid choice, not a slot to fill from a fixed list).",
"",
"ADDED 2026-08-18 (order from ki-produktivitetsmodell, census 09 on that",
"repo): its README is `nb` (see `locales` above) and carries",
"`## Virkeområde og forbehold`, which does the job the Non-goals contract",
"exists for — a stranger sees what the repo does not try to be. The operator",
"decided the README stays as written; this entry is the mechanism, not an",
"exception for one repo. Verified against the clone before writing it down."
],
"heading_aliases": {
"ki-produktivitetsmodell": {
"## Non-goals": "## Virkeområde og forbehold"
}
},
"$comment_tags_lightweight_accepted": [
@ -201,15 +256,36 @@
"the very gap REMOTE-SYNC exists to name, so these are claims about three",
"OTHER repos and were sent to them by coord so a wrong name can be disputed.",
"",
"13 entries, not the 14 lightweight tags that exist. `ktg-plugin-marketplace",
"v7.7.2` is DELIBERATELY ABSENT: it is that repo's newest tag, the one",
"lightweight tag with a safe remedy (`git tag -a -f`), and it is an ERROR",
"today. Pre-accepting it would mean that cutting v7.8.0 instead of fixing it",
"makes the finding vanish silently — the cheap fix skipped and unrecorded.",
"If it ages into history un-remedied, a fresh WARN is the correct signal."
"`ktg-plugin-marketplace v7.7.2` was DELIBERATELY ABSENT until 2026-08-14:",
"as the newest tag it had the one safe remedy (`git tag -a -f`), and",
"pre-accepting it would have let cutting v7.8.0 skip the cheap fix",
"unrecorded. ACCEPTED 2026-08-14 on the catalog's measured counter (their",
"coord, catalog HEAD d0d5d70): v7.7.2 is a monorepo-era llm-security tag",
"(2026-05-19, pre-split), not a catalog release; their README install block",
"pins no ref and all 12 marketplace.json refs are plugin-repo tags, so zero",
"install paths consume it — 'newest tag' here sorts last without being the",
"newest RELEASE, which is the premise the safe-remedy rule rests on.",
"Force-pushing a published ref for zero consumers costs more than the",
"finding. The catalog committed to annotating every future tag; the check",
"itself enforces that, since a new lightweight newest tag is not on this",
"list and fires ERROR.",
"",
"This entry was DEAD WEIGHT from 2026-08-14 to 2026-08-18: `accepted` was",
"only ever consulted for tags OLDER than newest, so v7.7.2 kept firing",
"TAG-ANNOTATED ERROR the whole time — the exact 'we decided this' vs.",
"'nobody looked' collapse this axis exists to prevent, one level down.",
"Caught and reported by the catalog itself (coord, 2026-08-17), fixed in",
"`checkTagIntegrity` (repo-standard, 2026-08-18): the accepted set is now",
"checked against the newest tag too, emitting a distinct",
"`TAG-ANNOTATED-ACCEPTED-NEWEST` OK rather than silently doing nothing.",
"",
"The two slash-named `config-audit/v*` tags on that forge are OUT OF SCOPE",
"by construction, not omitted by a fetch gap: the engine reads",
"`refs/tags/v*` — the repo's own version line — and a namespaced",
"monorepo-era tag is not part of it."
],
"tags_lightweight_accepted": {
"ktg-plugin-marketplace": ["v3.4.1", "v5.0.0", "v5.0.1", "v5.0.2", "v5.0.3", "v7.7.0", "v7.7.1"],
"ktg-plugin-marketplace": ["v3.4.1", "v5.0.0", "v5.0.1", "v5.0.2", "v5.0.3", "v7.7.0", "v7.7.1", "v7.7.2"],
"llm-ingestion-okf": ["v0.2.0", "v0.3.0", "v0.3.1", "v0.3.2", "v0.4.0"],
"llm-ingestion-pipeline-security": ["v0.3.0"]
},

View file

@ -538,12 +538,19 @@ export function checkInstallPins({ readme, forgeTagsByRepo }, register) {
// outsider who finds a hole, and being solo does not remove them.
function requirementsFor(klass, traits, register) {
const cls = register.classes?.[klass] ?? {};
const files = [...(cls.required_files ?? [])];
const classSource = `class \`${klass}\``;
const files = (cls.required_files ?? []).map((file) => ({ file, source: classSource }));
const headings = [...(cls.required_headings ?? [])];
const seenFiles = new Set(files.map((f) => f.file));
for (const t of traits ?? []) {
const tr = register.trait_requirements?.[t];
if (!tr) continue;
for (const f of tr.required_files ?? []) if (!files.includes(f)) files.push(f);
const traitSource = `trait \`${t}\``;
for (const f of tr.required_files ?? []) {
if (seenFiles.has(f)) continue;
seenFiles.add(f);
files.push({ file: f, source: traitSource });
}
for (const h of tr.required_headings ?? []) if (!headings.includes(h)) headings.push(h);
}
return { files, headings };
@ -553,9 +560,9 @@ export function checkRequiredFiles({ present, klass, traits }, register) {
const { files: required } = requirementsFor(klass, traits, register);
const have = new Set(present ?? []);
const findings = [];
for (const f of required) {
if (!have.has(f)) {
findings.push({ level: 'ERROR', code: 'FILE-MISSING', bucket: 'missing', msg: `missing required file for class \`${klass}\`: ${f}` });
for (const { file, source } of required) {
if (!have.has(file)) {
findings.push({ level: 'ERROR', code: 'FILE-MISSING', bucket: 'missing', msg: `missing required file for ${source}: ${file}` });
}
}
if (findings.length === 0 && required.length > 0) {
@ -568,16 +575,32 @@ export function checkRequiredFiles({ present, klass, traits }, register) {
// on a predictable heading is what agents pattern-match on, and `## Non-goals`
// is the cheapest trust-builder there is: it proves someone thought about the
// boundary, and it stops misuse before it starts.
export function checkHeadings({ readme, klass, traits }, register) {
export function checkHeadings({ readme, klass, traits, name }, register) {
const { headings: required } = requirementsFor(klass, traits, register);
const text = String(readme ?? '');
const present = new Set(
text.split('\n').map((l) => l.trim()).filter((l) => l.startsWith('#')),
);
const aliases = register.heading_aliases?.[name] ?? {};
const findings = [];
for (const h of required) {
if ([...present].some((p) => p.toLowerCase() === h.toLowerCase())) continue;
// Same job `titles` does for a README H1, one requirement over: the
// decision (this repo's readers were declared `nb`, so the contract's
// English wording is the wrong test) is taken in the repo, the bookkeeping
// happens here. Keyed per repo, not per locale — two nb-repos need not
// phrase the same section the same way.
const alias = aliases[h];
if (alias && [...present].some((p) => p.toLowerCase() === alias.toLowerCase())) {
findings.push({
level: 'OK',
code: 'HEADING-ALIAS',
msg: `\`${alias}\` satisfies \`${h}\` — the registered heading alias for \`${name}\``,
});
continue;
}
// Same title, wrong depth: say that, rather than "missing". The contract
// wants a predictable top-level heading because that is what an agent
// pattern-matches on — but the section does exist, and the fix is a
@ -701,15 +724,27 @@ export function checkTagIntegrity({ tagObjects, name }, register) {
const findings = [];
const newest = tags[tags.length - 1];
if (!newest.annotated) {
findings.push({
level: 'ERROR',
code: 'TAG-ANNOTATED',
bucket: 'broken',
msg: `newest tag \`${newest.name}\` is lightweight — it can be moved to another commit with no record that it ever pointed elsewhere, and the catalog pins releases by tag. Re-cut it annotated: \`git tag -a -f ${newest.name} ${newest.name}^{}\`.`,
});
}
const accepted = new Set(register?.tags_lightweight_accepted?.[name] ?? []);
if (!newest.annotated) {
if (accepted.has(newest.name)) {
// Named acceptance, not a standing exemption: only THIS exact tag is
// excused, so a later real release still fires ERROR the moment it
// becomes newest and isn't itself on the list (proven by the sibling
// test below).
findings.push({
level: 'OK',
code: 'TAG-ANNOTATED-ACCEPTED-NEWEST',
msg: `newest tag \`${newest.name}\` is lightweight, but the register accepts it by name as an exception to the newest-tag rule — a safe remedy exists (\`git tag -a -f\`) but costs more than the finding for this specific tag. Any OTHER tag that becomes newest is still judged.`,
});
} else {
findings.push({
level: 'ERROR',
code: 'TAG-ANNOTATED',
bucket: 'broken',
msg: `newest tag \`${newest.name}\` is lightweight — it can be moved to another commit with no record that it ever pointed elsewhere, and the catalog pins releases by tag. Re-cut it annotated: \`git tag -a -f ${newest.name} ${newest.name}^{}\`.`,
});
}
}
const olderLightweight = tags.slice(0, -1).filter((t) => !t.annotated);
const older = olderLightweight.filter((t) => !accepted.has(t.name));
const excused = olderLightweight.filter((t) => accepted.has(t.name));
@ -835,15 +870,20 @@ export function checkTagSigned({ tagObjects }, register) {
level: 'SKIP',
skip: 'byDesign',
code: 'TAG-SIGNED-LIGHTWEIGHT',
msg: `${lightweight.length} tag(s) cut under the policy are lightweight (${lightweight.map((t) => t.name).join(', ')}) — a lightweight tag has no tag object to carry a signature, so signing is not a remedy it has. TAG-ANNOTATED owns that finding; this check declines rather than report one defect twice.`,
msg: `${lightweight.length} tag(s) cut under the policy are lightweight (${lightweight.map((t) => t.name).join(', ')}) — a lightweight tag has no tag object to carry a signature, so signing is not a remedy it has. TAG-ANNOTATED owns the verdict on these — an ERROR, or an OK if the register names one as an accepted exception; this check declines rather than report one defect twice.`,
});
}
if (findings.length === 0 && inScope.length > 0) {
findings.push({ level: 'OK', code: 'TAGS-SIGNED', msg: `all ${inScope.length} tag(s) cut under the policy (from ${from}) are signed` });
}
if (findings.length === 0) {
findings.push({ level: 'OK', code: 'TAGS-SIGNED', msg: `no tag has been cut since the signing policy took effect (${from}) — nothing to judge yet` });
// The affirmative verdict is gated on the JUDGED findings only, never on
// `findings.length`. Gating on the whole list suppressed it the moment a
// pre-policy OK was present — which is every repo in the org, so this OK was
// near-unreachable in practice, and a reader could not tell "signed its new
// tag" from "has cut no tag since the policy". Two different facts, both green.
const judged = findings.some((f) => f.level === 'ERROR' || f.level === 'WARN');
if (!judged) {
findings.push(inScope.length > 0
? { level: 'OK', code: 'TAGS-SIGNED', msg: `all ${inScope.length} tag(s) cut under the policy (from ${from}) are signed` }
: { level: 'OK', code: 'TAGS-SIGNED', msg: `no tag has been cut since the signing policy took effect (${from}) — nothing to judge yet` });
}
return findings;
}
@ -1344,6 +1384,20 @@ function isFixturePath(path) {
// A home directory is what makes a `file:` URL a leak rather than a scheme the
// gate declines to resolve. Anchored on the two roots a real machine path
// starts with; a bare `file:///abs/path.html` placeholder is not one.
//
// WIDENING TO OTHER ROOTS WAS CONSIDERED AND REJECTED FOR NOW — an order
// (`.claude`, 2026-08-18) asked whether `C:\`, `/private/`, `/var/`, `/tmp/`
// and similar roots deserved the same anchor, having measured only the
// `/Users/ktg` case. Measured here across every `.md` file in the 21 of 22
// registered repos with a local clone (`llm-security-commons` absent):
// exactly two REAL leaks exist in the entire corpus, both `/Users/...` in
// `ki-produktivitetsmodell` (already caught — the positive control that
// proves this grep can find one). Every other `file://` hit is a generic
// placeholder (`/abs/path`, `<abs...>`, `${...}`) that anchoring on Users|home
// already declines to flag. Zero occurrences of any other real root, in this
// corpus, on this date — a RELEASE-ASSETS-shaped rejection: not a low rate to
// veto later, an absent subject. Widen this the day a real one appears, not
// before; re-measure rather than trust this count if this comment goes stale.
const FILE_URL_LEAK = /^file:\/\/\/?(Users|home)\//i;
// Relative file links only. Anchor resolution depends on per-renderer heading
@ -1518,7 +1572,7 @@ export function classifyRepo(
...checkInstallBlock({ readme, name, klass }, register),
...checkInstallTruth({ name, klass, catalogNames }),
...checkInstallPins({ readme, forgeTagsByRepo }, register),
...checkHeadings({ readme, klass, traits }, register),
...checkHeadings({ readme, klass, traits, name }, register),
...checkRequiredFiles({ present, klass, traits }, register),
...checkLinks({ files }, register),
...checkInternalLinks({ files, present }),

View file

@ -7,7 +7,10 @@
// They are the reason this gate has three outcomes instead of a boolean.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { readFileSync, mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
countCodepoints,
normalizeRepoRef,
@ -47,6 +50,7 @@ import {
checkVerifyCommand,
countTestFiles,
codeLines,
inspectRepo,
} from './repo-standard-check.mjs';
const REGISTER = {
@ -766,6 +770,27 @@ test('org-profile requires no headings at all', () => {
assert.equal(f.filter((x) => x.level === 'ERROR').length, 0);
});
// The Non-goals contract is written in English and matched literally, which
// cannot be satisfied by a repo whose readers were declared `nb` — the fix is
// per-repo, not per-locale, because two nb-repos need not phrase the same
// section the same way. Same shape as `titles`: the decision is taken in the
// repo, the bookkeeping happens in the register.
test('a registered heading alias satisfies a required heading for a different-language reader', () => {
const aliasRegister = {
...REGISTER,
heading_aliases: { 'ki-produktivitetsmodell': { '## Non-goals': '## Virkeområde og forbehold' } },
};
const readme = '# x\n## Virkeområde og forbehold\n';
const f = checkHeadings({ readme, klass: 'shared-asset', name: 'ki-produktivitetsmodell' }, aliasRegister);
assert.equal(f.some((x) => x.level === 'ERROR'), false);
assert.equal(f.some((x) => x.code === 'HEADING-ALIAS'), true);
// The alias is keyed per-repo: a different repo with the same Norwegian
// heading still misses the literal `## Non-goals`.
const other = checkHeadings({ readme, klass: 'shared-asset', name: 'some-other-repo' }, aliasRegister);
assert.equal(other.some((x) => x.code === 'HEADING-MISSING'), true);
});
// ------------------------------------------------------------- file: URL links
// A `file:///Users/ktg/...` link is dead for every reader but its author, and
@ -811,6 +836,57 @@ test('other schemes stay somebody else\'s to resolve', () => {
assert.equal(checkInternalLinks({ files, present: ['README.md'] }).some((x) => x.code === 'LINK-FILE-URL'), false);
});
// ------------------------------------------------- I/O shell: file loading
//
// Every test above feeds `files` to checkInternalLinks directly — deliberate,
// per the file header: the I/O shell is exercised live, not unit-tested. One
// exception, here: an order (`.claude`, 2026-08-18) diagnosed the file://
// rule as dead because it believed inspectRepo only ever loads README.md —
// wrong (git blame: every tracked .md file, since 2026-07-27, 816ba97) — but
// its point about the TEST SUITE stood: feeding `files` by hand is exactly
// the shortcut that would let every test above stay green while a real
// narrowing of inspectRepo's `.filter((p) => p.endsWith('.md'))` silently
// killed the rule in production. These two go through the real loading path.
function tempGitRepo(files) {
const dir = mkdtempSync(join(tmpdir(), 'repo-standard-io-'));
execFileSync('git', ['init', '-q'], { cwd: dir });
for (const [name, content] of Object.entries(files)) {
const path = join(dir, name);
mkdirSync(join(path, '..'), { recursive: true });
writeFileSync(path, content);
}
execFileSync('git', ['add', '-A'], { cwd: dir });
return dir;
}
test('inspectRepo scans a file: leak in a non-README markdown file through the ordinary loading path', () => {
const dir = tempGitRepo({
'README.md': '# test\n',
'docs/plan.md': '[notes](file:///Users/ktg/repos/x/notes.md)\n',
});
try {
const result = inspectRepo(dir, 'llm-ingestion-pipeline-security', REGISTER, null, null, true);
const hit = result.findings.find((f) => f.code === 'LINK-FILE-URL');
assert.ok(hit, 'LINK-FILE-URL did not fire for a leak outside README.md');
assert.match(hit.msg, /docs\/plan\.md:1/);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('inspectRepo raises nothing when no file: leak exists anywhere', () => {
const dir = tempGitRepo({
'README.md': '# test\n',
'docs/plan.md': 'no links here\n',
});
try {
const result = inspectRepo(dir, 'llm-ingestion-pipeline-security', REGISTER, null, null, true);
assert.equal(result.findings.some((f) => f.code === 'LINK-FILE-URL'), false);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
// -------------------------------------------------------------- tag integrity
// A lightweight tag is a branch-like ref: it can be moved to a different commit
@ -903,15 +979,54 @@ test('an unaccepted lightweight tag still fires, and the WARN counts only the un
});
// The newest tag is what a consumer resolves today and what an operator can
// re-cut at no cost. It is the one lightweight tag with a safe remedy, so it
// is the one that cannot be accepted away.
test('the NEWEST lightweight tag is still an ERROR even when the register accepts its name', () => {
const reg = { tags_lightweight_accepted: { alpha: ['v2.0.0'] } };
const f = checkTagIntegrity({ tagObjects: [{ name: 'v1.0.0', annotated: true }, { name: 'v2.0.0', annotated: false }] }, reg);
// re-cut at no cost, so acceptance is never inherited from a repo merely
// having OTHER accepted entries — only an exact name match on THIS tag excuses
// it (the exception directly below).
test('an unaccepted newest lightweight tag is still an ERROR, even when the register has other entries for this repo', () => {
const reg = { tags_lightweight_accepted: { alpha: ['v9.9.9'] } };
const f = checkTagIntegrity({ tagObjects: [{ name: 'v1.0.0', annotated: true }, { name: 'v2.0.0', annotated: false }], name: 'alpha' }, reg);
const hit = f.find((x) => x.code === 'TAG-ANNOTATED');
assert.equal(hit.level, 'ERROR');
});
// A tag that sorts "newest" by version string without being the newest
// RELEASE — a monorepo-era tag predating a split, consumed by nothing — has a
// real safe remedy (`git tag -a -f`, same commit) that still costs more than
// the finding when nothing resolves it. The register names the tag by EXACT
// NAME, never "this repo's newest is always excused" — caught the moment
// `ktg-plugin-marketplace v7.7.2` was found dead weight: the register had
// accepted it since 2026-08-14 (repos.json `tags_lightweight_accepted`) but
// `accepted` was only ever consulted for tags OLDER than newest, so the
// recorded decision could never take effect. Reported by the catalog
// (coord, 2026-08-17): measured against the real register and real tag data,
// exactly two findings — ERROR TAG-ANNOTATED on the (then-)inert acceptance,
// OK TAG-ANNOTATED-ACCEPTED for the 7 older accepted tags.
test('the newest lightweight tag IS excused when the register names it exactly, with a distinct OK code', () => {
const reg = { tags_lightweight_accepted: { alpha: ['v2.0.0'] } };
const f = checkTagIntegrity({ tagObjects: [{ name: 'v1.0.0', annotated: true }, { name: 'v2.0.0', annotated: false }], name: 'alpha' }, reg);
assert.equal(f.some((x) => x.code === 'TAG-ANNOTATED'), false);
const ok = f.find((x) => x.code === 'TAG-ANNOTATED-ACCEPTED-NEWEST');
assert.equal(ok.level, 'OK');
assert.match(ok.msg, /v2\.0\.0/);
});
// Acceptance names ONE tag, not a standing exemption for "whatever is newest".
// A real new tag cut after the accepted one is still judged the moment it
// becomes newest and isn't itself on the list — exactly what the register's
// own comment promises: "a new lightweight newest tag is not on this list and
// fires ERROR".
test('a NEW lightweight tag cut after an accepted newest is still judged', () => {
const reg = { tags_lightweight_accepted: { alpha: ['v2.0.0'] } };
const f = checkTagIntegrity({ tagObjects: [
{ name: 'v1.0.0', annotated: true },
{ name: 'v2.0.0', annotated: false },
{ name: 'v3.0.0', annotated: false },
], name: 'alpha' }, reg);
const hit = f.find((x) => x.code === 'TAG-ANNOTATED');
assert.equal(hit.level, 'ERROR');
assert.match(hit.msg, /v3\.0\.0/);
});
test('acceptance is per repo — a name accepted for one repo does not excuse another', () => {
const tagObjects = [{ name: 'v0.1.0', annotated: false }, { name: 'v1.0.0', annotated: true }];
const f = checkTagIntegrity({ tagObjects, name: 'beta' }, ACCEPT_REG);
@ -1004,6 +1119,35 @@ test('tags predating the policy are never judged — and say so as an OK, not si
assert.match(ok.msg, /2026-08-13/);
});
// Caught by dogfooding the v0.11.0 release: the affirmative verdict was
// suppressed whenever ANY pre-policy tag existed — which is every repo in the
// org, so `TAGS-SIGNED` would have been near-unreachable in practice. The
// reader could then not tell "this repo signed its new tag" from "this repo has
// cut no tag since the policy". Both are OK-status, and they are not the same
// fact. The exemption OK must not silence the judged one.
test('the affirmative OK survives alongside the pre-policy OK — they are two different facts', () => {
const f = checkTagSigned({ tagObjects: [
{ name: 'v0.9.0', annotated: true, signed: false, date: '2026-08-09' },
{ name: 'v1.0.0', annotated: true, signed: true, date: '2026-08-14' },
] }, SIGN_REG);
assert.equal(f.some((x) => x.level === 'ERROR' || x.level === 'WARN'), false);
assert.equal(f.some((x) => x.code === 'TAG-SIGNED-PREPOLICY'), true);
const ok = f.find((x) => x.code === 'TAGS-SIGNED');
assert.equal(ok.level, 'OK');
assert.match(ok.msg, /\b1 tag/);
});
// The other half of the same distinction: nothing cut since the policy is a
// DIFFERENT sentence from "what was cut is signed", and it must not borrow the
// affirmative one's wording.
test('a repo with only pre-policy tags says nothing has been cut yet, not that anything passed', () => {
const f = checkTagSigned({ tagObjects: [
{ name: 'v0.9.0', annotated: true, signed: false, date: '2026-08-09' },
] }, SIGN_REG);
const ok = f.find((x) => x.code === 'TAGS-SIGNED');
assert.match(ok.msg, /no tag has been cut/);
});
test('a tag cut exactly ON the policy date is in scope — the policy starts that day', () => {
const f = checkTagSigned({ tagObjects: [
{ name: 'v1.0.0', annotated: true, signed: false, date: '2026-08-13' },
@ -1451,6 +1595,19 @@ test('a repo without the security trait owes no SECURITY.md', () => {
assert.equal(f.some((x) => x.msg.includes('SECURITY.md')), false);
});
test('a trait-required file is missing, FILE-MISSING names the trait, not the class', () => {
const f = checkRequiredFiles({ present: ['README.md', 'LICENSE'], klass: 'standalone', traits: ['security'] }, REGISTER);
const finding = f.find((x) => x.code === 'FILE-MISSING' && x.msg.includes('SECURITY.md'));
assert.equal(finding.msg.includes('trait `security`'), true);
assert.equal(finding.msg.includes('class `standalone`'), false);
});
test('a class-required file is missing, FILE-MISSING still names the class', () => {
const f = checkRequiredFiles({ present: [], klass: 'standalone' }, REGISTER);
const finding = f.find((x) => x.code === 'FILE-MISSING' && x.msg.includes('LICENSE'));
assert.equal(finding.msg.includes('class `standalone`'), true);
});
test('the security trait requires limitations to be stated', () => {
const f = checkHeadings({ readme: '# x\n## Install\n## Non-goals\n', klass: 'standalone', traits: ['security'] }, REGISTER);
assert.equal(f.some((x) => x.code === 'HEADING-MISSING' && x.msg.includes('Known limitations')), true);