okf-wiki
Builds an Open Knowledge Format (OKF) knowledge base from existing docs, notes, or a repo. Use to scaffold an OKF wiki.
Install
npx skills add https://github.com/jamditis/claude-skills-journalism/tree/master/okf-wiki
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install jamditis-claude-skills-journalism@llmmart
git clone https://github.com/jamditis/claude-skills-journalism.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole jamditis/claude-skills-journalism collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
okf-wiki: scaffold an Open Knowledge Format knowledge base
OKF (Open Knowledge Format) stores knowledge as small markdown files: one concept per
file, each carrying its own provenance in YAML frontmatter, with directory index.md
files for navigation and a validator that enforces the contract. It is built for
knowledge bases that both people and agents read and edit, newsroom institutional
memory, a research atlas, a team's decision log, an infrastructure map.
This skill scaffolds a conforming OKF project and validates it. The format contract is
in spec/SPEC.md (in this skill's directory), read it before changing structure.
When to use
- The user wants to start an OKF knowledge base, atlas, or wiki.
- They want docs structured as one-concept-per-file with provenance, not prose pages.
- They want to "initialize OKF" in a repo, optionally publishing into its GitHub wiki.
Start here: scope the wiki with the user
Before you scaffold anything, settle four things with the user. They shape what gets created and
how it is published, and they are awkward to retrofit once concepts exist. Ask with AskUserQuestion
rather than in prose, in two steps: the first three questions in one call, then the publish question
as a follow-up call only if the audience came back public or both (it does not apply to an
internal-only wiki, and its relevant options depend on that answer, so it cannot share the first
batch). Infer the title from the repo or project and confirm it. Skip any question the user already
answered in their request, do not re-ask what they have told you.
- Audience, who reads this wiki? This answer sets the others:
- Internal (agents and teammates): the orientation hooks earn their keep, so keep them on.
The bundle may hold infrastructure detail, so it usually lives in a private repo. The in-repo
bundle/is the source of truth. - Public (people browsing): readability and secret-scrubbing come first; the hooks matter less, since people read it and agents do not. Plan a published view (see Publish below).
- Both: the in-repo
bundle/is the source of truth with hooks on for agents, plus a published view for people. Default here when the user is unsure.
- Internal (agents and teammates): the orientation hooks earn their keep, so keep them on.
The bundle may hold infrastructure detail, so it usually lives in a private repo. The in-repo
- Title and sections, the knowledge-base title (infer it, then confirm) and the starting
sections. Offer sections as a use-case preset, not a blank prompt:
- Newsroom institutional memory:
people, orgs, sources, decisions, beats - Research atlas:
concepts, sources, methods, findings - Infrastructure or fleet map:
machines, services, networks, credentials, processes - Decision log:
decisions, context, eventsThe chosen title and list feed--titleand--sectionsbelow; the user can edit the list.
- Newsroom institutional memory:
- Populate now or later, author concepts now from existing material (a repo, docs, notes, or a URL: gather it and enter the authoring loop after scaffolding), or scaffold an empty tree the user fills in later.
- Publish target, a follow-up
AskUserQuestioncall, made only after the audience comes back public or both (skip it entirely for an internal-only wiki):- In-repo bundle only (default): the validator and relative links work directly, with no extra surface to maintain. Right for most wikis.
- GitHub wiki: an optional reading surface. Advanced and manual, see "Optional: publish into
a GitHub wiki" below, bootstrapped with
scripts/gh-wiki-bootstrap.py. - GitHub Pages: a browsable site rendered from the bundle. Not built yet, treat it as a goal and keep the in-repo bundle as the source of truth.
Carry the answers into the scaffold command (the title and sections, plus --no-hooks if the user
opts out of the hooks for a public-only wiki) and into the populate step. The audience answer is
also the visibility decision the "Before finishing" section asks you to make deliberately, you are
making it here, up front, where it can steer the rest of the setup.
What gets created
scripts/scaffold.py writes a project that passes its own validator by construction:
<target>/
SPEC.md the OKF format contract
README.md how to use and validate the bundle
scripts/validate.py the validator
.claude/ Claude Code adapter: session-orientation hooks
settings.json registers the hooks (Claude Code approves them once)
hooks/okf-anchor.py SessionStart: load the index into context
hooks/okf-orient.py PreToolUse: gate the first action on orientation
bundle/ the OKF bundle (the validated tree)
index.md carries okf_version: "0.3" by default; "0.4" with --trust-signals
<section>/
index.md
example-concept.md a starter concept with full frontmatter
Docs and tooling sit at the project root; only bundle/ is validated. Keep them
separate, the validator treats every non-reserved .md inside the bundle as a
concept that needs frontmatter, so a stray SPEC.md inside bundle/ would fail.
The .claude/ hooks sit outside bundle/, so they never trip the concept checks.
How to run it
${CLAUDE_SKILL_DIR} below is this skill's own directory (the folder holding this
SKILL.md). Claude Code substitutes it with the real absolute path before you run the
command, so it works regardless of the current directory. On Windows, use python instead
of python3 (stock Windows has no python3). The --title and --sections come from the
onboarding answers above, and --no-hooks only if the user opted out. Scaffold into a new
directory; it validates automatically at the end:
python3 "${CLAUDE_SKILL_DIR}/scripts/scaffold.py" ./my-knowledge-base \
--title "Team knowledge base" \
--sections concepts,services,decisions
Default section is concepts. Use --force to write into a non-empty directory,
--no-validate to skip the validation run, and --date YYYY-MM-DD to set the sample
frontmatter date. The session hooks are written by default; --no-hooks skips them and
--hooks-os posix|windows overrides the auto-detected launch command (see below).
Validate any time, from the scaffolded project root (use python on Windows):
python3 scripts/validate.py --bundle bundle # must exit 0
Populate the bundle: author concepts from existing material
Scaffolding leaves an empty tree with one placeholder concept. The usual next request,
"here are my docs / plans / notes / repo, build the wiki", has no importer script, and
can't have one: deciding what counts as a single concept, writing its one-line description,
choosing its type, and pointing source at real provenance is judgment work, not a
mechanical transform. So you (Claude) author the concepts directly, in this loop:
- Gather the source. Read what the user pointed you at, a file, a folder, a repo, or a URL (fetch a URL first). Skim the whole thing before writing anything, so you can see the natural concept boundaries.
- Decide concept boundaries. One file is one concept: one thing a reader would look up on
its own (a service, a decision, a path, a person, an event). Split a doc that covers five
things into five concepts; merge fragments that only mean something together into one. A
heading is a hint, not a rule, do not blindly map one
##to one file. - Draft each concept at
bundle/<section>/<slug>.mdwith the full frontmatter. Read the bundle-rootindex.mdbefore writing so the verification key matches its declared format: useverifiedforokf_version0.1through0.3; useverified_onforokf_version0.4. Emit that exact key withtype, title, description, source, timestamp, tags:typefrom the vocab. Infrastructure: Machine, Network, Service, Session, Project, Repo, Credential, Path, Process. Domain-neutral: Concept, Decision, Event, Person, Org, Source. Plus Reference (the catch-all). The set is closed; an unlisted type fails.descriptionis one line.source, quote every element, points at where the fact actually came from (the origin file path, URL, command, or event), not at this skill.- Set
timestampto today.verified/verified_onis the date the fact was last confirmed true, set it by how you came to know it, not reflexively to today:- You re-checked it against reality now, or the user is the authority for it (a decision, preference, or intent they state in this session): today.
- The user is recalling external or system state (a spec, a path, a config): their memory is a source claim, not a re-check, so date it to when that state was last checked or to the recollection's own date, not today just because it came up now.
- It was copied from a dated source without re-checking: the date it was last known true (the source's own date), not today.
- It came from an undated record you cannot re-confirm (a memory file, an old conversation):
the oldest date you can evidence, file timestamp, introducing commit, or the date it was
said, never today. If you cannot evidence any date at all, it is not yet a verifiable fact;
find a datable source or leave the concept out.
When the date is uncertain, round it down: an older
verifiedcorrectly reads as "may be stale, re-check," while today reads as "just confirmed." The frontmatter date is the contract; a caveat in the body does not undo an overstated value, because the validator and tools read only the date.
- Strip secret values as you go: a credential concept names the key and its retrieval path, never the value. The validator fails the build on a leaked secret.
- Place and link. Put each concept in the right section (create sections as needed), add a
bullet for it to that section's
index.md, and cross-link related concepts with relative[text](path.md)links, not[[slug]]wikilinks.[[slug]]is the auto-memory idiom; the OKF validator rejects it and never resolves it, so a typo'd or deleted reference passes silently. When you create a new section, also link it from the bundle-rootindex.md, that root is the navigation map the session anchor loads, so a section missing from it is invisible to orientation even though validation still passes. - Clear the placeholder. If you scaffolded fresh, delete the starter
example-concept.md(and its bullet in the sectionindex.md) once real concepts exist, otherwise the sample ships in the finished wiki and still passes validation. - Validate in a loop. Run
python3 scripts/validate.py --bundle bundle, fix what it reports, repeat until it exits 0. Unquotedsourceelements and missing frontmatter keys are the common failures. Author in batches and validate between them rather than writing fifty files and debugging the lot.
When the source is already OKF
If the user points you at an existing OKF bundle (e.g. an upstream example: an index.md
carrying okf_version plus concept files with frontmatter), you are adopting it, not importing
it. Copy or clone the tree in, point the validator at the new root, and fix any links that broke
in the move. To keep it as its own area beside other content, give it a uniquely named top
directory, then create one combined-root index.md that carries okf_version and strip the
frontmatter from each adopted bundle's own root index.md, turning it into a normal section index
(the validator allows okf_version on the one combined root only; a nested index.md that still
carries it fails validation). Write cross-links as relative paths and validate the combined root.
Re-authoring an already-conforming bundle into your own concepts is wasted work; only reshape it if
that is the actual goal.
The format, briefly
Full contract in spec/SPEC.md. This spec is a strict fork of Google's upstream OKF: it
requires all seven frontmatter keys, uses a source list in place of upstream's resource
and # Citations, adds a verification-date key, closes the type vocab, and enforces link
resolution. spec/SPEC.md ("Relationship to upstream OKF") lists every difference. The
load-bearing rules:
- Required frontmatter on every concept:
type, title, description, source, the version-specific verification key described above,timestamp, tags.typeis one of: Machine, Network, Service, Session, Project, Repo, Credential, Path, Process (infrastructure); Concept, Decision, Event, Person, Org, Source (domain-neutral); or Reference (catch-all). - Quote every
sourceelement, source pointers carry#and:which break YAML if unquoted.source: ["README.md", "issue #445"]. verified/verified_onis the date the fact was last confirmed true, a re-check against reality, or the user stating a fact they are the authority for (a decision, a preference); a fact they merely recall about external state is a source claim, not a re-check.timestampis when the concept was authored/updated. The verification date is ISOYYYY-MM-DD;timestampmay also be a full ISO 8601 datetime in0.3and0.4. See the authoring loop above for the full date rules.- No secret values, ever. A credential concept documents the key name and retrieval path, never the value. The validator fails the build on a leaked secret.
index.mdandlog.mdare reserved, no frontmatter (except the bundle-rootindex.md, which carriesokf_versiononly).
Optional: upstream v0.2 trust/provenance signals
Upstream Google OKF v0.2 (July 2026) added an optional vocabulary for a consumer to judge a
concept before reading it: generated (who/what produced it), verified (a list of
independent confirmations, not this fork's own single-date field), sources (structured,
per-pointer credibility signals), status (draft/stable/deprecated), stale_after (an
absolute expiry date), and an Attested Computation type for a sanctioned, checkable
computation. None of it is required, and a bundle that adopts none of it is unaffected.
Scaffold a project with these enabled, scaffold.py <target> --trust-signals, and the
bundle declares okf_version: "0.4", with verified renamed to verified_on in the
required set (freeing verified for the new shape; see spec/SPEC.md's "Trust and
provenance" section for the full field contract and the reasoning behind the rename).
Attested Computation is likewise a 0.4-only type. Without the flag, scaffolding is
unchanged from before this vocabulary existed.
Session hooks
A scaffolded project ships a .claude/ with two hooks so any Claude session opened in it
starts from the bundle, not from memory:
okf-anchor.py(SessionStart) prints the bundle's root index into the session context.okf-orient.py(PreToolUse, no matcher) blocks the first action of the session once, until Claude confirms it read the index, then unblocks for the rest of the session. It is inert outside an OKF bundle and fails open on any error, so it never wedges a session.
Both are one cross-platform python3 script. The scripts are identical on every OS; only the
interpreter in .claude/settings.json changes: python3 on macOS/Linux, python on
Windows. scaffold.py auto-detects the OS; --hooks-os posix|windows forces it.
Claude Code treats a checked-in .claude/settings.json as untrusted, so the first time the
project is opened it asks the user to approve the hooks; they run automatically after that.
To turn them off, scaffold with --no-hooks, or delete .claude/ (or set disableAllHooks)
in an existing project.
Client boundary
The portable OKF surface is SPEC.md, requirements.txt, scripts/validate.py, and the
bundle/ tree. The generated README.md documents both that shared surface and any enabled
client adapter. The three generated .claude/ files are a Claude Code adapter, not part of
the OKF format and not shared Codex behavior. Codex does not read them as project configuration,
and this skill must not claim that their SessionStart or PreToolUse lifecycle runs there.
The general onboarding route above still names Claude Code's AskUserQuestion and
${CLAUDE_SKILL_DIR} surfaces. The recorded Codex pilot pre-set every onboarding choice and
used an explicit project-relative installed path; it does not establish that the unadapted
general route is portable.
For a mixed Claude Code and Codex project, keep .claude/ so Claude Code can request trust
and use the hooks; Codex leaves it inert. For a Codex-only project, pass --no-hooks while
scaffolding or delete .claude/ afterward. Either choice leaves the portable bundle and
validator unchanged.
Optional: publish into a GitHub wiki
OKF lives best as in-repo files (the validator and relative links work directly). A repo's GitHub wiki is an optional reading surface, and wiring it up is an advanced, manual step, most users should skip it and keep the bundle in-repo.
A wiki with zero pages has no git repo to push to and no API, so the very first page must be
created through the web UI. scripts/gh-wiki-bootstrap.py automates that one step, but it
drives a real logged-in browser, so it needs two things you provide yourself (a GitHub PAT
does not work, wiki pages are a web-UI-only surface):
- Playwright with Chromium installed:
pip install playwright && playwright install chromium. - A saved GitHub web session: a Playwright
storageStateJSON, captured from a browser where you have already logged into GitHub. The script reuses that session; it does not log in for you. Pass its path with--state(default:~/.cache/gh_state.json).
python3 "${CLAUDE_SKILL_DIR}/scripts/gh-wiki-bootstrap.py" owner/repo --state path/to/gh_state.json
# then: git clone https://github.com/owner/repo.wiki.git and push your pages
Note the impedance: GitHub wikis are flatter than an OKF tree and use [[WikiLinks]], so
OKF's nested directories and relative links need adapting for the wiki surface. Treat the
wiki as a published view, not the source of truth. (v0.1 ships the bootstrap step; an
automatic bundle-to-wiki sync is not built yet.)
Before finishing
- Run the validator and confirm it exits 0.
- Confirm the visibility you set during onboarding still fits what got authored: a bundle that ended up documenting real infrastructure is usually internal. OKF takes no position; you must.
Files (claude-skills-journalism)
-
.claude-plugin
-
plugin.json 404 B
{ "name": "okf-wiki", "version": "0.8.3", "description": "Scaffold an Open Knowledge Format (OKF) knowledge base from docs, plans, notes, or a repo: one-concept-per-file Markdown with YAML frontmatter, navigation, and validation. Built for newsroom memory, research atlases, decision logs, and infrastructure maps.", "author": { "name": "Joe Amditis", "email": "jamditis@gmail.com" } }
-
-
agents
-
openai.yaml 136 B
interface: display_name: "OKF wiki" short_description: "Builds an Open Knowledge Format (OKF) knowledge base from existing docs…"
-
-
example
-
.claude
-
hooks
-
okf-anchor.py 3.6 KB
#!/usr/bin/env python3 """SessionStart hook: orient Claude on this OKF knowledge base. Prints the bundle's root index (the map of the knowledge base) so it lands in the session context, and work starts from the map instead of from memory. Claude Code injects a SessionStart hook's stdout into the session. Resolves the bundle relative to $CLAUDE_PROJECT_DIR (set by Claude Code), then this script's own location, then the cwd, so it works wherever the hook is launched from. No-ops silently if there is no bundle index. Never fails a session: a genuine error exits 0, but reports the reason on stderr so a broken bundle is not silently dropped. This is one cross-platform python3 script; only the launch command in .claude/settings.json differs per OS (python3 on macOS/Linux, python on Windows). """ import os import sys from pathlib import Path REL_CANDIDATES = ("bundle/index.md", "index.md") def find_index(): """Resolve the bundle root index.md. Order: $CLAUDE_PROJECT_DIR, then this script's install dir (<project>/.claude/ hooks/), then a walk up from the cwd. The first two pin the root directly. The cwd walk climbs upward looking for bundle/index.md, so a launch from inside bundle/<section>/ still resolves the root map, not the section's own index.md. A bare index.md at the cwd is the bundle-less last resort. """ bases = [] env_dir = os.environ.get("CLAUDE_PROJECT_DIR") if env_dir: bases.append(Path(env_dir)) bases.append(Path(__file__).resolve().parent.parent.parent) # <project>/.claude/hooks/ for base in bases: for rel in REL_CANDIDATES: p = base / rel if p.is_file(): return p cwd = Path.cwd().resolve() for ancestor in (cwd, *cwd.parents): p = ancestor / "bundle" / "index.md" if p.is_file(): return p p = cwd / "index.md" return p if p.is_file() else None def strip_frontmatter(text): """Drop a leading YAML frontmatter block (--- ... ---) if present.""" lines = text.splitlines() if lines and lines[0].strip() == "---": for i in range(1, len(lines)): if lines[i].strip() == "---": return "\n".join(lines[i + 1:]).strip() return text.strip() def main(): index = find_index() if index is None: return 0 # utf-8-sig strips a leading BOM; without it a BOM defeats the "---" frontmatter # check in strip_frontmatter and the raw YAML block would leak into the context. body = strip_frontmatter(index.read_text(encoding="utf-8-sig")) if not body: return 0 print( "OKF_ANCHOR: this project is an Open Knowledge Format (OKF) knowledge base. " "Orient on the index below before acting on it. It maps the concepts, their " "provenance, and how the bundle is organized. Drill into a section's index.md, " "then open only the concept you need; re-check the map when the task shifts area." ) print("--- begin OKF index ---") print(body) print("--- end OKF index ---") return 0 if __name__ == "__main__": try: sys.exit(main()) except Exception as exc: # noqa: BLE001 - last-resort guard around the whole hook # Stay fail-open (a broken bundle must not break the session), but not # silently: the orient gate tells Claude the index was injected, so a silent # anchor failure would make that claim false and hard to diagnose. sys.stderr.write( f"OKF anchor hook: could not inject the OKF index ({exc!r}); continuing " "without it. The orientation gate may reference an index that is not in " "context.\n" ) sys.exit(0) -
okf-orient.py 6.2 KB
#!/usr/bin/env python3 """PreToolUse hook: gate the first action on reading the OKF index. Blocks the first tool call of a session once (exit 2, reason on stderr), then unblocks for the rest of the session. The SessionStart hook (okf-anchor.py) has already placed the index in context; this is the speed bump that forces orientation before the first action. Fires only inside an OKF bundle (a bundle/index.md or index.md is present), so it is inert in any other project. Stateful per (session, project): a marker under the user's private cache dir is written on the first (blocking) call, so the immediate retry and everything after it pass. Never wedges a session and never silently disables the gate: on any error it surfaces the reason on stderr, then allows. Wired with no matcher in .claude/settings.json, so it sees the first tool call of any kind (Bash, Read, Edit, a tool from an MCP server, anything). This is one cross-platform python3 script; only the launch command differs per OS. """ import hashlib import json import os import sys from pathlib import Path REL_CANDIDATES = ("bundle/index.md", "index.md") def state_dir(): """Return the per-user directory that holds orientation markers. Deliberately NOT the world-shared system temp root: on a multi-user host another user could pre-create a marker there and bypass the gate. The default is the user's private cache dir (XDG_CACHE_HOME or ~/.cache), created 0700. Set OKF_ORIENT_STATE_DIR to override it (tests point it at a scratch path). """ override = os.environ.get("OKF_ORIENT_STATE_DIR") if override: base = Path(override) else: cache = os.environ.get("XDG_CACHE_HOME") or (Path.home() / ".cache") base = Path(cache) / "okf-orient" base.mkdir(parents=True, exist_ok=True, mode=0o700) return base def find_index(): """Resolve the bundle root index.md. Order: $CLAUDE_PROJECT_DIR, then this script's install dir (<project>/.claude/ hooks/), then a walk up from the cwd. The first two pin the root directly. The cwd walk climbs upward looking for bundle/index.md, so a launch from inside bundle/<section>/ still resolves the root map, not the section's own index.md. A bare index.md at the cwd is the bundle-less last resort. The marker key derives from this path's parent, so keying stays stable on the root regardless of cwd. """ bases = [] env_dir = os.environ.get("CLAUDE_PROJECT_DIR") if env_dir: bases.append(Path(env_dir)) bases.append(Path(__file__).resolve().parent.parent.parent) # <project>/.claude/hooks/ for base in bases: for rel in REL_CANDIDATES: p = base / rel if p.is_file(): return p cwd = Path.cwd().resolve() for ancestor in (cwd, *cwd.parents): p = ancestor / "bundle" / "index.md" if p.is_file(): return p p = cwd / "index.md" return p if p.is_file() else None def main(): raw = sys.stdin.read() data = json.loads(raw) if raw.strip() else {} index = find_index() if index is None: return 0 # not an OKF bundle: do not gate session_id = data.get("session_id") if not session_id: # Without a stable session id, "once per session" cannot be implemented: a # constant fallback key would make the gate fire once ever and skip every # later session in this project, and blocking without persisting would wedge # the session (the retry has no id either). So skip the gate, visibly. The # index was still injected at session start. sys.stderr.write( "OKF orientation gate: no session_id in the hook payload, so per-session " "state cannot be tracked; allowing without gating.\n" ) return 0 # Key the marker on (session, project) so a reused session id in another # project does not skip that project's gate. Hash keeps it filesystem-safe. key = hashlib.sha256( f"{session_id}|{index.resolve().parent}".encode("utf-8") ).hexdigest()[:16] # Record orientation before blocking, so the immediate retry and the rest of # the session pass. If the marker cannot be persisted (state dir unwritable, or # its parent already exists as a file), blocking would re-fire on every retry and # wedge the session -- so allow instead, but say so on stderr. This is a visible # fail-open, not the silent one the outer handler would give. try: marker = state_dir() / (key + ".oriented") if marker.exists(): return 0 # already oriented this session: allow marker.write_text("", encoding="utf-8") # set now so the immediate retry passes try: os.chmod(marker, 0o600) # owner-only; the state dir is already 0700 except OSError: pass # perms are defense-in-depth; the private dir is the real control except OSError as exc: sys.stderr.write( f"OKF orientation gate: could not record orientation state ({exc}); " "allowing this action so the session is not blocked. The bundle index was " "still placed in your context at session start.\n" ) return 0 sys.stderr.write( "OKF orientation gate (fires once per session): this is an Open Knowledge " "Format knowledge base and this is the first action of the session. The OKF " "index was placed in your context at session start (the bundle root index.md). " "Confirm you have read it -- briefly note what this bundle covers -- then retry " "your action. It will proceed; this gate does not fire again this session.\n" ) return 2 # block this one call if __name__ == "__main__": try: sys.exit(main()) except Exception as exc: # noqa: BLE001 - last-resort guard around the whole hook # Never wedge a session on a hook error, but never silently disable the gate # either: surface the failure, then allow the call. (SystemExit from a normal # return is not an Exception, so a real block still propagates.) sys.stderr.write( f"OKF orientation gate: unexpected hook error ({exc!r}); allowing this " "action so the session is not blocked.\n" ) sys.exit(0)
-
-
settings.json 450 B
{ "hooks": { "SessionStart": [ { "hooks": [ { "type": "command", "command": "python3 \"${CLAUDE_PROJECT_DIR}/.claude/hooks/okf-anchor.py\"" } ] } ], "PreToolUse": [ { "hooks": [ { "type": "command", "command": "python3 \"${CLAUDE_PROJECT_DIR}/.claude/hooks/okf-orient.py\"" } ] } ] } }
-
-
bundle
-
hooks
-
accessibility-check.md 550 B
--- type: Reference title: "accessibility-check hook" description: "Check for alt text, heading structure, and accessibility in content" source: ["hooks/accessibility-check.md", "CLAUDE.md"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["hook", "writing-quality"] --- # accessibility-check hook Check for alt text, heading structure, and accessibility in content **Event:** `PostToolUse` | **Tools:** Write, Edit | **Category:** Writing quality One of the repository's standalone [hooks](index.md). Source: `hooks/accessibility-check.md`. -
ai-slop-detector.md 532 B
--- type: Reference title: "ai-slop-detector hook" description: "Warn about AI-generated writing patterns that erode reader trust" source: ["hooks/ai-slop-detector.md", "CLAUDE.md"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["hook", "writing-quality"] --- # ai-slop-detector hook Warn about AI-generated writing patterns that erode reader trust **Event:** `PostToolUse` | **Tools:** Write, Edit | **Category:** Writing quality One of the repository's standalone [hooks](index.md). Source: `hooks/ai-slop-detector.md`. -
ap-style-check.md 496 B
--- type: Reference title: "ap-style-check hook" description: "Flag common AP Style violations in written content" source: ["hooks/ap-style-check.md", "CLAUDE.md"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["hook", "writing-quality"] --- # ap-style-check hook Flag common AP Style violations in written content **Event:** `PostToolUse` | **Tools:** Write, Edit | **Category:** Writing quality One of the repository's standalone [hooks](index.md). Source: `hooks/ap-style-check.md`. -
archive-reminder.md 534 B
--- type: Reference title: "archive-reminder hook" description: "Remind to archive URLs when citing web sources in journalism content" source: ["hooks/archive-reminder.md", "CLAUDE.md"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["hook", "preservation"] --- # archive-reminder hook Remind to archive URLs when citing web sources in journalism content **Event:** `PostToolUse` | **Tools:** Write, Edit | **Category:** Preservation One of the repository's standalone [hooks](index.md). Source: `hooks/archive-reminder.md`. -
bug-report-detector.md 537 B
--- type: Reference title: "bug-report-detector hook" description: "Detects bug reports and reminds Claude to follow test-first workflow" source: ["hooks/bug-report-detector.md", "CLAUDE.md"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["hook", "development"] --- # bug-report-detector hook Detects bug reports and reminds Claude to follow test-first workflow **Event:** `UserPromptSubmit` | **Tools:**, | **Category:** Development One of the repository's standalone [hooks](index.md). Source: `hooks/bug-report-detector.md`. -
copywriting-preflight.md 587 B
--- type: Reference title: "copywriting-preflight hook" description: "Detects writing and revision requests and prompts an intent interview before drafting" source: ["hooks/copywriting-preflight.md", "CLAUDE.md"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["hook", "writing-quality"] --- # copywriting-preflight hook Detects writing and revision requests and prompts an intent interview before drafting **Event:** `UserPromptSubmit` | **Tools:**, | **Category:** Writing quality One of the repository's standalone [hooks](index.md). Source: `hooks/copywriting-preflight.md`. -
data-methodology-check.md 552 B
--- type: Reference title: "data-methodology-check hook" description: "Ensure data journalism content includes methodology documentation" source: ["hooks/data-methodology-check.md", "CLAUDE.md"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["hook", "verification"] --- # data-methodology-check hook Ensure data journalism content includes methodology documentation **Event:** `PostToolUse` | **Tools:** Write, Edit | **Category:** Verification One of the repository's standalone [hooks](index.md). Source: `hooks/data-methodology-check.md`. -
deadline-tracker.md 533 B
--- type: Reference title: "deadline-tracker hook" description: "Surface upcoming deadlines from editorial workflow at session start" source: ["hooks/deadline-tracker.md", "CLAUDE.md"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["hook", "editorial-workflow"] --- # deadline-tracker hook Surface upcoming deadlines from editorial workflow at session start **Event:** `SessionStart` | **Tools:**, | **Category:** Editorial workflow One of the repository's standalone [hooks](index.md). Source: `hooks/deadline-tracker.md`. -
enforce-test-first.md 533 B
--- type: Reference title: "enforce-test-first hook" description: "Blocks code edits during bug fixing until a test has been written" source: ["hooks/enforce-test-first.md", "CLAUDE.md"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["hook", "development"] --- # enforce-test-first hook Blocks code edits during bug fixing until a test has been written **Event:** `PreToolUse` | **Tools:** Edit, Write | **Category:** Development One of the repository's standalone [hooks](index.md). Source: `hooks/enforce-test-first.md`. -
index.md 1.1 KB
# hooks 17 standalone workflow hooks under `hooks/`. Most are non-blocking warnings; `one-way-door-check`, `enforce-test-first`, and `no-ai-attribution` block intentionally. See [the hooks catalog](../systems/hooks-catalog.md) for how they install and fire. ## Writing quality - [accessibility-check](accessibility-check.md) - [ai-slop-detector](ai-slop-detector.md) - [ap-style-check](ap-style-check.md) - [copywriting-preflight](copywriting-preflight.md) ## Verification - [data-methodology-check](data-methodology-check.md) - [source-attribution-check](source-attribution-check.md) - [verification-reminder](verification-reminder.md) ## Editorial workflow - [deadline-tracker](deadline-tracker.md) - [legal-review-flag](legal-review-flag.md) - [pre-publish-checklist](pre-publish-checklist.md) - [source-diversity-check](source-diversity-check.md) ## Preservation - [archive-reminder](archive-reminder.md) ## Development - [bug-report-detector](bug-report-detector.md) - [enforce-test-first](enforce-test-first.md) - [no-ai-attribution](no-ai-attribution.md) - [one-way-door-check](one-way-door-check.md) - [pre-commit-review](pre-commit-review.md) -
legal-review-flag.md 540 B
--- type: Reference title: "legal-review-flag hook" description: "Flag potentially defamatory or legally risky content for review" source: ["hooks/legal-review-flag.md", "CLAUDE.md"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["hook", "editorial-workflow"] --- # legal-review-flag hook Flag potentially defamatory or legally risky content for review **Event:** `PostToolUse` | **Tools:** Write, Edit | **Category:** Editorial workflow One of the repository's standalone [hooks](index.md). Source: `hooks/legal-review-flag.md`. -
no-ai-attribution.md 526 B
--- type: Reference title: "no-ai-attribution hook" description: "Blocks AI authorship credit in git and gh commands before they land" source: ["hooks/no-ai-attribution.md", "CLAUDE.md"] verified: 2026-06-30 timestamp: 2026-06-30 tags: ["hook", "development"] --- # no-ai-attribution hook Blocks AI authorship credit in git and gh commands before they land **Event:** `PreToolUse` | **Tools:** Bash | **Category:** Development One of the repository's standalone [hooks](index.md). Source: `hooks/no-ai-attribution.md`. -
one-way-door-check.md 791 B
--- type: Reference title: "one-way-door-check hook" description: "Blocks creation of files that represent irreversible architectural decisions until the user confirms." source: ["hooks/one-way-door-check.md", "CLAUDE.md"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["hook", "development"] --- # one-way-door-check hook Blocks creation of files that represent irreversible architectural decisions until the user confirms. Requires the companion PostToolUse:AskUserQuestion hook (one-way-door- approve), which promotes the session's pending files to approved so the retry passes, install both, not just this check. **Event:** `PreToolUse` | **Tools:** Write | **Category:** Development One of the repository's standalone [hooks](index.md). Source: `hooks/one-way-door-check.md`. -
pre-commit-review.md 618 B
--- type: Reference title: "pre-commit-review hook" description: "Surface the staged diff for line-by-line review before a commit, and flag deletions of safety-critical guardrails" source: ["hooks/pre-commit-review.md", "CLAUDE.md"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["hook", "development"] --- # pre-commit-review hook Surface the staged diff for line-by-line review before a commit, and flag deletions of safety-critical guardrails **Event:** `PreToolUse` | **Tools:** Bash | **Category:** Development One of the repository's standalone [hooks](index.md). Source: `hooks/pre-commit-review.md`. -
pre-publish-checklist.md 607 B
--- type: Reference title: "pre-publish-checklist hook" description: "Remind about verification, legal review, and publication checks before completing journalism tasks" source: ["hooks/pre-publish-checklist.md", "CLAUDE.md"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["hook", "editorial-workflow"] --- # pre-publish-checklist hook Remind about verification, legal review, and publication checks before completing journalism tasks **Event:** `Stop` | **Tools:**, | **Category:** Editorial workflow One of the repository's standalone [hooks](index.md). Source: `hooks/pre-publish-checklist.md`. -
source-attribution-check.md 570 B
--- type: Reference title: "source-attribution-check hook" description: "Flag unattributed quotes, claims, and statistics in journalism content" source: ["hooks/source-attribution-check.md", "CLAUDE.md"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["hook", "verification"] --- # source-attribution-check hook Flag unattributed quotes, claims, and statistics in journalism content **Event:** `PostToolUse` | **Tools:** Write, Edit | **Category:** Verification One of the repository's standalone [hooks](index.md). Source: `hooks/source-attribution-check.md`. -
source-diversity-check.md 564 B
--- type: Reference title: "source-diversity-check hook" description: "Note when sources in an article may lack diversity of perspective" source: ["hooks/source-diversity-check.md", "CLAUDE.md"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["hook", "editorial-workflow"] --- # source-diversity-check hook Note when sources in an article may lack diversity of perspective **Event:** `PostToolUse` | **Tools:** Write, Edit | **Category:** Editorial workflow One of the repository's standalone [hooks](index.md). Source: `hooks/source-diversity-check.md`. -
verification-reminder.md 550 B
--- type: Reference title: "verification-reminder hook" description: "Prompt to verify facts before including them in journalism content" source: ["hooks/verification-reminder.md", "CLAUDE.md"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["hook", "verification"] --- # verification-reminder hook Prompt to verify facts before including them in journalism content **Event:** `PostToolUse` | **Tools:** Write, Edit | **Category:** Verification One of the repository's standalone [hooks](index.md). Source: `hooks/verification-reminder.md`.
-
-
plugins
-
autocontext.md 685 B
--- type: Reference title: "autocontext plugin" description: "Cross-session knowledge persistence: lessons accumulate per skill and fold back in." source: ["autocontext/.claude-plugin/plugin.json", ".claude-plugin/marketplace.json"] verified: 2026-06-23 timestamp: 2026-06-23 tags: [plugin, productivity] --- # autocontext plugin Carries knowledge across sessions and developers. Lessons are captured by hooks, validated, surfaced in later sessions, and `/autocontext:evolve` folds them back into skill files. Ships hooks, commands, and agents rather than skills. Install with `/plugin install autocontext@claude-skills-journalism`. See [the marketplace](../systems/marketplace.md). -
dev-toolkit.md 1.4 KB
--- type: Reference title: "dev-toolkit plugin" description: "Thirteen development skills for small newsroom dev teams." source: ["dev-toolkit/.claude-plugin/plugin.json", ".claude-plugin/marketplace.json"] verified: 2026-08-21 timestamp: 2026-08-21 tags: [plugin, development] --- # dev-toolkit plugin Thirteen skills: accessibility (WCAG 2.2), context engineering, directed execution, Electron patterns, mobile/remote debugging, irreversible-decision discipline, Python data pipelines, test-first bug fixing, AI-assisted development, ethical web scraping, no-build frontend patterns, web-UI taste, and CLAUDE.md context maintenance. Install with `/plugin install dev-toolkit@claude-skills-journalism`. ## Skills - [accessibility-compliance](../skills/accessibility-compliance.md) - [claude-md-updater](../skills/claude-md-updater.md) - [context-engineering-fundamentals](../skills/context-engineering-fundamentals.md) - [director](../skills/director.md) - [electron-dev](../skills/electron-dev.md) - [mobile-debugging](../skills/mobile-debugging.md) - [one-way-door](../skills/one-way-door.md) - [python-pipeline](../skills/python-pipeline.md) - [test-first-bugs](../skills/test-first-bugs.md) - [vibe-coding](../skills/vibe-coding.md) - [web-scraping](../skills/web-scraping.md) - [web-ui-best-practices](../skills/web-ui-best-practices.md) - [zero-build-frontend](../skills/zero-build-frontend.md) -
index.md 1 KB
# plugins The 12 plugins registered in [the marketplace](../systems/marketplace.md). Each concept records what the plugin is, its skill or command count, and where it lives. For individual skills, see the [skills section](../skills/index.md). - [autocontext](autocontext.md) - cross-session knowledge (hooks/commands/agents, no skills) - [dev-toolkit](dev-toolkit.md) - 13 development skills - [journalism-core](journalism-core.md) - 15 journalism skills - [okf-wiki](okf-wiki.md) - the OKF scaffolder (1 skill) - [pdf-design](pdf-design.md) - PDF design system (1 skill) - [pdf-playground](pdf-playground.md) - interactive document builder (1 skill, 8 commands) - [project-templates-toolkit](project-templates-toolkit.md) - 3 project-setup skills - [research-toolkit](research-toolkit.md) - 6 research skills - [security-toolkit](security-toolkit.md) - 4 security skills, 1 command - [superjawn](superjawn.md) - 14 workflow skills - [video-toolkit](video-toolkit.md) - 4 social-video reporting skills - [visual-explainer](visual-explainer.md) - HTML diagrams (1 skill) -
journalism-core.md 1.5 KB
--- type: Reference title: "journalism-core plugin" description: "Fifteen core journalism skills for reporting, verification, and publishing." source: ["journalism-core/.claude-plugin/plugin.json", ".claude-plugin/marketplace.json"] verified: 2026-08-21 timestamp: 2026-08-21 tags: [plugin, journalism] --- # journalism-core plugin Fifteen skills: AP-style writing, AI-slop detox, source verification (deepfake/ C2PA), US and Brazilian records requests, fact-checking, interview prep and transcription, story pitches, editorial workflow, crisis communications, newsletter publishing, data journalism, social-media OSINT, and embedded photo metadata. ## Skills - [ai-writing-detox](../skills/ai-writing-detox.md) - [brazil-records-requests](../skills/brazil-records-requests.md) - [crisis-communications](../skills/crisis-communications.md) - [data-journalism](../skills/data-journalism.md) - [editorial-workflow](../skills/editorial-workflow.md) - [fact-check-workflow](../skills/fact-check-workflow.md) - [foia-requests](../skills/foia-requests.md) - [interview-prep](../skills/interview-prep.md) - [interview-transcription](../skills/interview-transcription.md) - [newsletter-publishing](../skills/newsletter-publishing.md) - [newsroom-style](../skills/newsroom-style.md) - [photo-metadata](../skills/photo-metadata.md) - [social-media-intelligence](../skills/social-media-intelligence.md) - [source-verification](../skills/source-verification.md) - [story-pitch](../skills/story-pitch.md) -
okf-wiki.md 643 B
--- type: Reference title: "okf-wiki plugin" description: "Scaffold an OKF knowledge base with a spec, a validator, and session-start hooks." source: ["okf-wiki/.claude-plugin/plugin.json", ".claude-plugin/marketplace.json"] verified: 2026-06-23 timestamp: 2026-06-23 tags: [plugin, productivity, okf] --- # okf-wiki plugin The skill that built this wiki. It scaffolds a conforming Open Knowledge Format bundle, validates it, and writes session-start hooks that orient Claude before it works. See [the OKF format](../systems/okf-format.md) and [the session hooks](../systems/session-hooks.md). ## Skills - [okf-wiki](../skills/okf-wiki.md) -
pdf-design.md 518 B
--- type: Reference title: "pdf-design plugin" description: "PDF report and proposal design system with reusable content blocks." source: ["pdf-design/.claude-plugin/plugin.json", ".claude-plugin/marketplace.json"] verified: 2026-06-23 timestamp: 2026-06-23 tags: [plugin, design] --- # pdf-design plugin A brand-variable PDF design system: budget tables and reusable blocks (stats strips, three-column, four-tile pillars, partner grids) for reports and proposals. ## Skills - [pdf-design](../skills/pdf-design.md) -
pdf-playground.md 606 B
--- type: Reference title: "pdf-playground plugin" description: "Interactive proposal/report/slide builder with a live control panel." source: ["pdf-playground/.claude-plugin/plugin.json", ".claude-plugin/marketplace.json"] verified: 2026-06-23 timestamp: 2026-06-23 tags: [plugin, design, productivity] --- # pdf-playground plugin Eight commands (`/proposal`, `/report`, `/onepager`, `/newsletter`, `/slides`, `/event`, `/preview`, `/update`) build branded documents with a live design control panel for colors, fonts, spacing, and sections. ## Skills - [Document design](../skills/document-design.md) -
project-templates-toolkit.md 700 B
--- type: Reference title: "project-templates-toolkit plugin" description: "Three skills for starting and closing out projects." source: ["project-templates-toolkit/.claude-plugin/plugin.json", ".claude-plugin/marketplace.json"] verified: 2026-06-23 timestamp: 2026-06-23 tags: [plugin, productivity] --- # project-templates-toolkit plugin Three skills: a CLAUDE.md project-memory writer, a LESSONS.md retrospective writer, and a template-selector decision tree that picks the right starting template for the work at hand. ## Skills - [project-memory](../skills/project-memory.md) - [project-retrospective](../skills/project-retrospective.md) - [template-selector](../skills/template-selector.md) -
research-toolkit.md 868 B
--- type: Reference title: "research-toolkit plugin" description: "Six skills for research, source preservation, and academic workflows." source: ["research-toolkit/.claude-plugin/plugin.json", ".claude-plugin/marketplace.json"] verified: 2026-06-23 timestamp: 2026-06-23 tags: [plugin, research] --- # research-toolkit plugin Six skills: academic writing, legal paywall-bypass strategies, web archiving, page monitoring and change detection, AI-enriched digital archives with entity extraction, and a curated free-API catalog with sunset-currency notes. ## Skills - [academic-writing](../skills/academic-writing.md) - [content-access](../skills/content-access.md) - [digital-archive](../skills/digital-archive.md) - [free-apis-catalog](../skills/free-apis-catalog.md) - [page-monitoring](../skills/page-monitoring.md) - [web-archiving](../skills/web-archiving.md) -
security-toolkit.md 763 B
--- type: Reference title: "security-toolkit plugin" description: "Four defensive security skills plus a sandboxed install-scan command." source: ["security-toolkit/.claude-plugin/plugin.json", ".claude-plugin/marketplace.json"] verified: 2026-06-23 timestamp: 2026-06-23 tags: [plugin, security] --- # security-toolkit plugin Four skills (pre-deployment OWASP checklist, secure authentication, API hardening, npm/bun supply-chain hardening) and the `/security-toolkit:hotpatch` command for a sandboxed pre-install scan plus cooldown bypass. ## Skills - [api-hardening](../skills/api-hardening.md) - [secure-auth](../skills/secure-auth.md) - [security-checklist](../skills/security-checklist.md) - [supply-chain-hardening](../skills/supply-chain-hardening.md) -
superjawn.md 1.3 KB
--- type: Reference title: "superjawn plugin" description: "Research-augmented fork of obra/superpowers; 14 standalone skills." source: ["superjawn/.claude-plugin/plugin.json", ".claude-plugin/marketplace.json"] verified: 2026-06-23 timestamp: 2026-06-23 tags: [plugin, productivity] --- # superjawn plugin Fourteen workflow skills (brainstorming, TDD, writing and executing plans, code review, git worktrees, systematic debugging, and more). A standalone fork of obra/superpowers with no upstream dependency. ## Skills - [brainstorming](../skills/brainstorming.md) - [dispatching-parallel-agents](../skills/dispatching-parallel-agents.md) - [executing-plans](../skills/executing-plans.md) - [finishing-a-development-branch](../skills/finishing-a-development-branch.md) - [receiving-code-review](../skills/receiving-code-review.md) - [requesting-code-review](../skills/requesting-code-review.md) - [subagent-driven-development](../skills/subagent-driven-development.md) - [systematic-debugging](../skills/systematic-debugging.md) - [test-driven-development](../skills/test-driven-development.md) - [using-git-worktrees](../skills/using-git-worktrees.md) - [using-superjawn](../skills/using-superjawn.md) - [verification-before-completion](../skills/verification-before-completion.md) - [writing-plans](../skills/writing-plans.md) - [writing-skills](../skills/writing-skills.md) -
video-toolkit.md 750 B
--- type: Reference title: "video-toolkit plugin" description: "Four composable skills for public social-video reporting and analysis." source: ["video-toolkit/.claude-plugin/plugin.json", ".claude-plugin/marketplace.json"] verified: 2026-08-15 timestamp: 2026-08-15 tags: [plugin, video, reporting] --- # video-toolkit plugin Four skills form a reporting pipeline for authorized public video collection, transcription, frame analysis, and interactive dashboard creation. Install with `/plugin install video-toolkit@claude-skills-journalism`. ## Skills - [video-download](../skills/video-download.md) - [video-transcribe](../skills/video-transcribe.md) - [video-frames](../skills/video-frames.md) - [video-dashboard](../skills/video-dashboard.md) -
visual-explainer.md 606 B
--- type: Reference title: "visual-explainer plugin" description: "HTML diagrams, data tables, and architecture views." source: ["visual-explainer/.claude-plugin/plugin.json", ".claude-plugin/marketplace.json"] verified: 2026-06-23 timestamp: 2026-06-23 tags: [plugin, design] --- # visual-explainer plugin Generates self-contained HTML pages with Mermaid diagrams, responsive section navigation, KPI cards, slide decks, and zoom/pan controls, via eight commands. Adapted from nicobailon/visual-explainer with newsroom design sensibilities. ## Skills - [visual-explainer](../skills/visual-explainer.md)
-
-
skills
-
academic-writing.md 1 KB
--- type: Reference title: "academic-writing skill" description: "Academic writing, research methodology, and scholarly communication workflows." source: ["research-toolkit/skills/academic-writing/SKILL.md", ".claude-plugin/marketplace.json"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["skill", "research-toolkit"] --- # academic-writing skill Academic writing, research methodology, and scholarly communication workflows. Use when writing papers, literature reviews, grant proposals, conducting research, managing citations, preparing for peer review, choosing OA routes under Plan S / 2026 OSTP Nelson Memo, posting preprints, working with persistent identifiers (ORCID, DOI, ROR), assigning CRediT contributor roles, preregistering analyses on OSF / AsPredicted, or disclosing LLM use to journals and funders. Essential for researchers, graduate students, and academics across disciplines. Part of the [research-toolkit plugin](../plugins/research-toolkit.md). Source: `research-toolkit/skills/academic-writing/SKILL.md`. -
accessibility-compliance.md 899 B
--- type: Reference title: "accessibility-compliance skill" description: "Web accessibility patterns for news sites, journalism tools, and academic platforms." source: ["dev-toolkit/skills/accessibility-compliance/SKILL.md", ".claude-plugin/marketplace.json"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["skill", "dev-toolkit"] --- # accessibility-compliance skill Web accessibility patterns for news sites, journalism tools, and academic platforms. Use when building accessible interfaces, auditing existing sites for WCAG compliance, writing alt text for news images, creating accessible data visualizations, or ensuring content reaches all readers including those using assistive technologies. Essential for newsroom developers and anyone publishing web content. Part of the [dev-toolkit plugin](../plugins/dev-toolkit.md). Source: `dev-toolkit/skills/accessibility-compliance/SKILL.md`. -
ai-writing-detox.md 724 B
--- type: Reference title: "ai-writing-detox skill" description: "Eliminate AI-generated writing patterns that erode reader trust." source: ["journalism-core/skills/ai-writing-detox/SKILL.md", ".claude-plugin/marketplace.json"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["skill", "journalism-core"] --- # ai-writing-detox skill Eliminate AI-generated writing patterns that erode reader trust. Activate when writing articles, documentation, press releases, or any content where AI patterns would undermine credibility. For journalists using AI assistance who need human-sounding output. Part of the [journalism-core plugin](../plugins/journalism-core.md). Source: `journalism-core/skills/ai-writing-detox/SKILL.md`. -
api-hardening.md 781 B
--- type: Reference title: "api-hardening skill" description: "API security hardening patterns." source: ["security-toolkit/skills/api-hardening/SKILL.md", ".claude-plugin/marketplace.json"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["skill", "security-toolkit"] --- # api-hardening skill API security hardening patterns. Use when implementing rate limiting, input validation, CORS configuration, API key management, request throttling, or protecting endpoints from abuse. Covers defense-in-depth strategies for REST APIs with practical implementations for Express, FastAPI, and serverless, oriented around the OWASP API Security Top 10:2023. Part of the [security-toolkit plugin](../plugins/security-toolkit.md). Source: `security-toolkit/skills/api-hardening/SKILL.md`. -
brainstorming.md 688 B
--- type: Reference title: "brainstorming skill" description: "You MUST use this before any creative work - creating features, building components, adding functionality, or modifying behavior." source: ["superjawn/skills/brainstorming/SKILL.md", ".claude-plugin/marketplace.json"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["skill", "superjawn"] --- # brainstorming skill You MUST use this before any creative work - creating features, building components, adding functionality, or modifying behavior. Explores user intent, requirements and design before implementation. Part of the [superjawn plugin](../plugins/superjawn.md). Source: `superjawn/skills/brainstorming/SKILL.md`. -
brazil-records-requests.md 649 B
--- type: Reference title: "brazil-records-requests skill" description: "Brazilian public records requests under the Access to Information Law." source: ["journalism-core/skills/brazil-records-requests/SKILL.md", ".claude-plugin/marketplace.json"] verified: 2026-08-21 timestamp: 2026-08-21 tags: ["skill", "journalism-core"] --- # brazil-records-requests skill Draft, file, track, and appeal public records requests under Brazil's Access to Information Law, including Fala.BR and the CGU and CMRI appeal path. Part of the [journalism-core plugin](../plugins/journalism-core.md). Source: `journalism-core/skills/brazil-records-requests/SKILL.md`. -
claude-md-updater.md 951 B
--- type: Reference title: "claude-md-updater skill" description: "Use this skill when the user asks to update CLAUDE.md, save a lesson, or persist something from the current session: phrases like \"update claude.md\", \"wha…" source: ["dev-toolkit/skills/claude-md-updater/SKILL.md", ".claude-plugin/marketplace.json"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["skill", "dev-toolkit"] --- # claude-md-updater skill Use this skill when the user asks to update CLAUDE.md, save a lesson, or persist something from the current session: phrases like "update claude.md", "what should we remember", "save this lesson", or "add to context". Scans the conversation for hard-won lessons, new file paths, infrastructure changes, and new workflows, then proposes scoped edits to the project's CLAUDE.md for approval before writing. Part of the [dev-toolkit plugin](../plugins/dev-toolkit.md). Source: `dev-toolkit/skills/claude-md-updater/SKILL.md`. -
content-access.md 767 B
--- type: Reference title: "content-access skill" description: "Legal methods for accessing paywalled and geo-blocked content." source: ["research-toolkit/skills/content-access/SKILL.md", ".claude-plugin/marketplace.json"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["skill", "research-toolkit"] --- # content-access skill Legal methods for accessing paywalled and geo-blocked content. Use when researching behind paywalls, accessing academic papers, bypassing geographic restrictions, or finding open access alternatives. Covers Unpaywall, library databases, VPNs, and ethical access strategies for journalists and researchers. Part of the [research-toolkit plugin](../plugins/research-toolkit.md). Source: `research-toolkit/skills/content-access/SKILL.md`. -
context-engineering-fundamentals.md 698 B
--- type: Reference title: "context-engineering-fundamentals skill" description: "Manage attention and evidence in long AI-agent sessions." source: ["dev-toolkit/skills/context-engineering-fundamentals/SKILL.md", ".claude-plugin/marketplace.json"] verified: 2026-08-15 timestamp: 2026-08-15 tags: ["skill", "dev-toolkit"] --- # context-engineering-fundamentals skill Keep instructions, evidence, and state available during long work. Measure retrieval and reasoning quality on the current model and task before compressing context or relying on fixed thresholds. Part of the [dev-toolkit plugin](../plugins/dev-toolkit.md). Source: `dev-toolkit/skills/context-engineering-fundamentals/SKILL.md`. -
crisis-communications.md 897 B
--- type: Reference title: "crisis-communications skill" description: "Crisis communication and rapid response workflows for journalists and communications professionals." source: ["journalism-core/skills/crisis-communications/SKILL.md", ".claude-plugin/marketplace.json"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["skill", "journalism-core"] --- # crisis-communications skill Crisis communication and rapid response workflows for journalists and communications professionals. Use when covering breaking news events, managing organizational communications during crises, coordinating rapid fact-checking efforts, or developing crisis response plans. Essential for newsrooms, PR teams, and anyone who needs to communicate accurately under time pressure. Part of the [journalism-core plugin](../plugins/journalism-core.md). Source: `journalism-core/skills/crisis-communications/SKILL.md`. -
data-journalism.md 765 B
--- type: Reference title: "data-journalism skill" description: "Data journalism workflows for analysis, visualization, and storytelling." source: ["journalism-core/skills/data-journalism/SKILL.md", ".claude-plugin/marketplace.json"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["skill", "journalism-core"] --- # data-journalism skill Data journalism workflows for analysis, visualization, and storytelling. Use when analyzing datasets, creating charts and maps, cleaning messy data, calculating statistics or building data-driven stories. Essential for reporters, newsrooms and researchers working with quantitative information. Part of the [journalism-core plugin](../plugins/journalism-core.md). Source: `journalism-core/skills/data-journalism/SKILL.md`. -
digital-archive.md 817 B
--- type: Reference title: "digital-archive skill" description: "Digital archiving workflows with AI enrichment, entity extraction, and knowledge graph construction." source: ["research-toolkit/skills/digital-archive/SKILL.md", ".claude-plugin/marketplace.json"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["skill", "research-toolkit"] --- # digital-archive skill Digital archiving workflows with AI enrichment, entity extraction, and knowledge graph construction. Use when building content archives, implementing AI-powered categorization, extracting entities and relationships, or integrating multiple data sources. Covers patterns from the Jay Rosen Digital Archive project. Part of the [research-toolkit plugin](../plugins/research-toolkit.md). Source: `research-toolkit/skills/digital-archive/SKILL.md`. -
director.md 675 B
--- type: Reference title: "director skill" description: "Direct one request through the lower-tier agents configured in the applicable policy." source: ["dev-toolkit/skills/director/SKILL.md", ".claude-plugin/marketplace.json"] verified: 2026-08-21 timestamp: 2026-08-21 tags: ["skill", "dev-toolkit"] --- # director skill Activate an explicit top-tier director role for one request. The director reads the applicable `CLAUDE.md` policy, delegates execution to the lower-tier agents configured there, reviews their results, and stays within the user's authority. Part of the [dev-toolkit plugin](../plugins/dev-toolkit.md). Source: `dev-toolkit/skills/director/SKILL.md`. -
dispatching-parallel-agents.md 629 B
--- type: Reference title: "dispatching-parallel-agents skill" description: "Use when facing 2+ independent tasks that can be worked on without shared state or sequential dependencies" source: ["superjawn/skills/dispatching-parallel-agents/SKILL.md", ".claude-plugin/marketplace.json"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["skill", "superjawn"] --- # dispatching-parallel-agents skill Use when facing 2+ independent tasks that can be worked on without shared state or sequential dependencies Part of the [superjawn plugin](../plugins/superjawn.md). Source: `superjawn/skills/dispatching-parallel-agents/SKILL.md`. -
document-design.md 909 B
--- type: Reference title: "Document design skill" description: "This skill should be used when the user asks to \"create a proposal\", \"design a report\", \"make a one-pager\", \"build a PDF\", \"create a newsletter\", \"design…" source: ["pdf-playground/skills/document-design/SKILL.md", ".claude-plugin/marketplace.json"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["skill", "pdf-playground"] --- # Document design skill This skill should be used when the user asks to "create a proposal", "design a report", "make a one-pager", "build a PDF", "create a newsletter", "design slides", "make event materials", "design a flyer", or needs help with print-ready HTML documents. Provides brand configuration, CSS patterns for print layout, and document design best practices. Part of the [pdf-playground plugin](../plugins/pdf-playground.md). Source: `pdf-playground/skills/document-design/SKILL.md`. -
editorial-workflow.md 776 B
--- type: Reference title: "editorial-workflow skill" description: "Manage editorial workflows for newsrooms and publications." source: ["journalism-core/skills/editorial-workflow/SKILL.md", ".claude-plugin/marketplace.json"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["skill", "journalism-core"] --- # editorial-workflow skill Manage editorial workflows for newsrooms and publications. Use when tracking story assignments, managing deadlines, coordinating editorial calendars, or establishing handoff protocols between reporters and editors. Includes templates for assignment tracking, editorial calendars, and workflow documentation. Part of the [journalism-core plugin](../plugins/journalism-core.md). Source: `journalism-core/skills/editorial-workflow/SKILL.md`. -
electron-dev.md 748 B
--- type: Reference title: "electron-dev skill" description: "Electron desktop application development with React, TypeScript, and Vite." source: ["dev-toolkit/skills/electron-dev/SKILL.md", ".claude-plugin/marketplace.json"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["skill", "dev-toolkit"] --- # electron-dev skill Electron desktop application development with React, TypeScript, and Vite. Use when building desktop apps, implementing IPC communication, managing windows/tray, handling PTY terminals, integrating WebRTC/audio, or packaging with electron-builder. Covers patterns from AudioBash, Yap, and Pisscord projects. Part of the [dev-toolkit plugin](../plugins/dev-toolkit.md). Source: `dev-toolkit/skills/electron-dev/SKILL.md`. -
executing-plans.md 577 B
--- type: Reference title: "executing-plans skill" description: "Use when you have a written implementation plan to execute in a separate session with review checkpoints" source: ["superjawn/skills/executing-plans/SKILL.md", ".claude-plugin/marketplace.json"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["skill", "superjawn"] --- # executing-plans skill Use when you have a written implementation plan to execute in a separate session with review checkpoints Part of the [superjawn plugin](../plugins/superjawn.md). Source: `superjawn/skills/executing-plans/SKILL.md`. -
fact-check-workflow.md 760 B
--- type: Reference title: "fact-check-workflow skill" description: "Structured workflow for fact-checking claims in journalism." source: ["journalism-core/skills/fact-check-workflow/SKILL.md", ".claude-plugin/marketplace.json"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["skill", "journalism-core"] --- # fact-check-workflow skill Structured workflow for fact-checking claims in journalism. Use when verifying statements for publication, rating claims for fact-check articles, or building pre- publication verification processes. Includes claim extraction, evidence gathering, rating scales, and correction protocols. Part of the [journalism-core plugin](../plugins/journalism-core.md). Source: `journalism-core/skills/fact-check-workflow/SKILL.md`. -
finishing-a-development-branch.md 786 B
--- type: Reference title: "finishing-a-development-branch skill" description: "Use when implementation is complete, all tests pass, and you need to decide how to integrate the work - guides completion of development work by presentin…" source: ["superjawn/skills/finishing-a-development-branch/SKILL.md", ".claude-plugin/marketplace.json"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["skill", "superjawn"] --- # finishing-a-development-branch skill Use when implementation is complete, all tests pass, and you need to decide how to integrate the work - guides completion of development work by presenting structured options for merge, PR, or cleanup Part of the [superjawn plugin](../plugins/superjawn.md). Source: `superjawn/skills/finishing-a-development-branch/SKILL.md`. -
foia-requests.md 758 B
--- type: Reference title: "foia-requests skill" description: "Freedom of Information Act (FOIA) and public records request workflows." source: ["journalism-core/skills/foia-requests/SKILL.md", ".claude-plugin/marketplace.json"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["skill", "journalism-core"] --- # foia-requests skill Freedom of Information Act (FOIA) and public records request workflows. Use when drafting records requests, tracking submissions, understanding exemptions, appealing denials, or managing large document productions. Essential for investigative journalists, researchers, and transparency advocates. Part of the [journalism-core plugin](../plugins/journalism-core.md). Source: `journalism-core/skills/foia-requests/SKILL.md`. -
free-apis-catalog.md 797 B
--- type: Reference title: "free-apis-catalog skill" description: "Use when suggesting APIs for a project, looking for free data sources, building weekend projects that need external data, or when the user needs weather,…" source: ["research-toolkit/skills/free-apis-catalog/SKILL.md", ".claude-plugin/marketplace.json"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["skill", "research-toolkit"] --- # free-apis-catalog skill Use when suggesting APIs for a project, looking for free data sources, building weekend projects that need external data, or when the user needs weather, news, finance, sports, ML, or entertainment data without paid subscriptions Part of the [research-toolkit plugin](../plugins/research-toolkit.md). Source: `research-toolkit/skills/free-apis-catalog/SKILL.md`. -
index.md 3.4 KB
# skills Every skill the marketplace ships, one concept per file, grouped by [plugin](../plugins/index.md). 63 skills across 11 plugins. ## [journalism-core](../plugins/journalism-core.md) - [ai-writing-detox](ai-writing-detox.md) - [brazil-records-requests](brazil-records-requests.md) - [crisis-communications](crisis-communications.md) - [data-journalism](data-journalism.md) - [editorial-workflow](editorial-workflow.md) - [fact-check-workflow](fact-check-workflow.md) - [foia-requests](foia-requests.md) - [interview-prep](interview-prep.md) - [interview-transcription](interview-transcription.md) - [newsletter-publishing](newsletter-publishing.md) - [newsroom-style](newsroom-style.md) - [photo-metadata](photo-metadata.md) - [social-media-intelligence](social-media-intelligence.md) - [source-verification](source-verification.md) - [story-pitch](story-pitch.md) ## [research-toolkit](../plugins/research-toolkit.md) - [academic-writing](academic-writing.md) - [content-access](content-access.md) - [digital-archive](digital-archive.md) - [free-apis-catalog](free-apis-catalog.md) - [page-monitoring](page-monitoring.md) - [web-archiving](web-archiving.md) ## [dev-toolkit](../plugins/dev-toolkit.md) - [accessibility-compliance](accessibility-compliance.md) - [claude-md-updater](claude-md-updater.md) - [context-engineering-fundamentals](context-engineering-fundamentals.md) - [director](director.md) - [electron-dev](electron-dev.md) - [mobile-debugging](mobile-debugging.md) - [one-way-door](one-way-door.md) - [python-pipeline](python-pipeline.md) - [test-first-bugs](test-first-bugs.md) - [vibe-coding](vibe-coding.md) - [web-scraping](web-scraping.md) - [web-ui-best-practices](web-ui-best-practices.md) - [zero-build-frontend](zero-build-frontend.md) ## [security-toolkit](../plugins/security-toolkit.md) - [api-hardening](api-hardening.md) - [secure-auth](secure-auth.md) - [security-checklist](security-checklist.md) - [supply-chain-hardening](supply-chain-hardening.md) ## [project-templates-toolkit](../plugins/project-templates-toolkit.md) - [project-memory](project-memory.md) - [project-retrospective](project-retrospective.md) - [template-selector](template-selector.md) ## [superjawn](../plugins/superjawn.md) - [brainstorming](brainstorming.md) - [dispatching-parallel-agents](dispatching-parallel-agents.md) - [executing-plans](executing-plans.md) - [finishing-a-development-branch](finishing-a-development-branch.md) - [receiving-code-review](receiving-code-review.md) - [requesting-code-review](requesting-code-review.md) - [subagent-driven-development](subagent-driven-development.md) - [systematic-debugging](systematic-debugging.md) - [test-driven-development](test-driven-development.md) - [using-git-worktrees](using-git-worktrees.md) - [using-superjawn](using-superjawn.md) - [verification-before-completion](verification-before-completion.md) - [writing-plans](writing-plans.md) - [writing-skills](writing-skills.md) ## [pdf-design](../plugins/pdf-design.md) - [pdf-design](pdf-design.md) ## [pdf-playground](../plugins/pdf-playground.md) - [Document design](document-design.md) ## [visual-explainer](../plugins/visual-explainer.md) - [visual-explainer](visual-explainer.md) ## [okf-wiki](../plugins/okf-wiki.md) - [okf-wiki](okf-wiki.md) ## [video-toolkit](../plugins/video-toolkit.md) - [video-dashboard](video-dashboard.md) - [video-download](video-download.md) - [video-frames](video-frames.md) - [video-transcribe](video-transcribe.md) -
interview-prep.md 775 B
--- type: Reference title: "interview-prep skill" description: "Prepare for journalism interviews with research checklists, question frameworks, and attribution guidelines." source: ["journalism-core/skills/interview-prep/SKILL.md", ".claude-plugin/marketplace.json"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["skill", "journalism-core"] --- # interview-prep skill Prepare for journalism interviews with research checklists, question frameworks, and attribution guidelines. Use when preparing to interview sources, planning follow-up questions, or managing interview logistics. Covers consent, recording laws, and professional protocols. Part of the [journalism-core plugin](../plugins/journalism-core.md). Source: `journalism-core/skills/interview-prep/SKILL.md`. -
interview-transcription.md 855 B
--- type: Reference title: "interview-transcription skill" description: "Transcription workflows, recording management, and quote extraction for journalists." source: ["journalism-core/skills/interview-transcription/SKILL.md", ".claude-plugin/marketplace.json"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["skill", "journalism-core"] --- # interview-transcription skill Transcription workflows, recording management, and quote extraction for journalists. Use when processing audio/video recordings, generating transcripts with timestamps, extracting quotes for fact-checking, or building source-and-recording databases. For interview question design and pre-interview preparation, see the interview-prep skill. Part of the [journalism-core plugin](../plugins/journalism-core.md). Source: `journalism-core/skills/interview-transcription/SKILL.md`. -
mobile-debugging.md 779 B
--- type: Reference title: "mobile-debugging skill" description: "Remote JavaScript console access and debugging on mobile devices." source: ["dev-toolkit/skills/mobile-debugging/SKILL.md", ".claude-plugin/marketplace.json"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["skill", "dev-toolkit"] --- # mobile-debugging skill Remote JavaScript console access and debugging on mobile devices. Use when debugging web pages on phones/tablets, accessing console errors without desktop DevTools, testing responsive designs on real devices, or diagnosing mobile-specific issues. Covers Eruda, vConsole, Chrome/Safari remote debugging, and cloud testing platforms. Part of the [dev-toolkit plugin](../plugins/dev-toolkit.md). Source: `dev-toolkit/skills/mobile-debugging/SKILL.md`. -
newsletter-publishing.md 846 B
--- type: Reference title: "newsletter-publishing skill" description: "Email newsletter workflows for journalists and researchers." source: ["journalism-core/skills/newsletter-publishing/SKILL.md", ".claude-plugin/marketplace.json"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["skill", "journalism-core"] --- # newsletter-publishing skill Email newsletter workflows for journalists and researchers. Use when creating, managing, or optimizing email newsletters, building subscriber lists, designing email templates, analyzing engagement metrics, or planning newsletter content calendars. For independent journalists, academic communicators, and media organizations building direct audience relationships. Part of the [journalism-core plugin](../plugins/journalism-core.md). Source: `journalism-core/skills/newsletter-publishing/SKILL.md`. -
newsroom-style.md 716 B
--- type: Reference title: "newsroom-style skill" description: "Enforce AP Style and newsroom conventions for journalism writing." source: ["journalism-core/skills/newsroom-style/SKILL.md", ".claude-plugin/marketplace.json"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["skill", "journalism-core"] --- # newsroom-style skill Enforce AP Style and newsroom conventions for journalism writing. Use when writing news articles, editing drafts, creating headlines, or converting notes into publishable copy. Ensures professional standards for attribution, numbers, dates, and formatting. Part of the [journalism-core plugin](../plugins/journalism-core.md). Source: `journalism-core/skills/newsroom-style/SKILL.md`. -
okf-wiki.md 964 B
--- type: Reference title: "okf-wiki skill" description: "Scaffold a new Open Knowledge Format (OKF) knowledge base and populate it from existing material: a tree of small markdown concept files with YAML frontma…" source: ["okf-wiki/SKILL.md", ".claude-plugin/marketplace.json"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["skill", "okf-wiki"] --- # okf-wiki skill Scaffold a new Open Knowledge Format (OKF) knowledge base and populate it from existing material: a tree of small markdown concept files with YAML frontmatter, a spec, a validator, and session-start hooks that orient Claude on the knowledge base before it works. Use when the user wants to start an OKF atlas/wiki/knowledge base, build one from existing docs, plans, notes, or a repo, structure docs as one-concept-per-file with provenance, or initialize OKF in a repo (optionally into its GitHub wiki). Part of the [okf-wiki plugin](../plugins/okf-wiki.md). Source: `okf-wiki/SKILL.md`. -
one-way-door.md 794 B
--- type: Reference title: "one-way-door skill" description: "Use this skill when creating new files that represent architectural decisions, data models, infrastructure configs, auth boundaries, API contracts, CI/CD…" source: ["dev-toolkit/skills/one-way-door/SKILL.md", ".claude-plugin/marketplace.json"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["skill", "dev-toolkit"] --- # one-way-door skill Use this skill when creating new files that represent architectural decisions, data models, infrastructure configs, auth boundaries, API contracts, CI/CD pipelines, or event systems. Flags irreversible decisions and forces a discussion about trade-offs before committing. Part of the [dev-toolkit plugin](../plugins/dev-toolkit.md). Source: `dev-toolkit/skills/one-way-door/SKILL.md`. -
page-monitoring.md 784 B
--- type: Reference title: "page-monitoring skill" description: "Web page monitoring, change detection, and availability tracking." source: ["research-toolkit/skills/page-monitoring/SKILL.md", ".claude-plugin/marketplace.json"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["skill", "research-toolkit"] --- # page-monitoring skill Web page monitoring, change detection, and availability tracking. Use when tracking content changes, detecting when pages go down, monitoring for updates, preserving content before deletion, or generating feeds for pages without RSS. Covers Visualping, ChangeTower, Distill.io, and self-hosted monitoring solutions. Part of the [research-toolkit plugin](../plugins/research-toolkit.md). Source: `research-toolkit/skills/page-monitoring/SKILL.md`. -
pdf-design.md 462 B
--- type: Reference title: "pdf-design skill" description: "Design and edit professional PDF reports and proposals with live preview" source: ["pdf-design/SKILL.md", ".claude-plugin/marketplace.json"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["skill", "pdf-design"] --- # pdf-design skill Design and edit professional PDF reports and proposals with live preview Part of the [pdf-design plugin](../plugins/pdf-design.md). Source: `pdf-design/SKILL.md`. -
photo-metadata.md 847 B
--- type: Reference title: "photo-metadata skill" description: "Use when preparing photos or images for a news wire, publication, photo CMS, or archive, embedding caption, byline, credit, alt text, keywords, copyright…" source: ["journalism-core/skills/photo-metadata/SKILL.md", ".claude-plugin/marketplace.json"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["skill", "journalism-core"] --- # photo-metadata skill Use when preparing photos or images for a news wire, publication, photo CMS, or archive, embedding caption, byline, credit, alt text, keywords, copyright or Creative Commons license, and location into a file's IPTC, EXIF, and XMP metadata, or batch-tagging a folder of press photos with exiftool. Part of the [journalism-core plugin](../plugins/journalism-core.md). Source: `journalism-core/skills/photo-metadata/SKILL.md`. -
project-memory.md 892 B
--- type: Reference title: "project-memory skill" description: "Generate CLAUDE.md project memory files that transfer institutional knowledge, not obvious information." source: ["project-templates-toolkit/skills/project-memory/SKILL.md", ".claude-plugin/marketplace.json"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["skill", "project-templates-toolkit"] --- # project-memory skill Generate CLAUDE.md project memory files that transfer institutional knowledge, not obvious information. Use when setting up new journalism projects, onboarding collaborators, or documenting project-specific quirks. Includes templates for editorial tools, event websites, publications, research projects, content pipelines, and digital archives. Part of the [project-templates-toolkit plugin](../plugins/project-templates-toolkit.md). Source: `project-templates-toolkit/skills/project-memory/SKILL.md`. -
project-retrospective.md 849 B
--- type: Reference title: "project-retrospective skill" description: "Generate LESSONS.md retrospective files that capture institutional knowledge, especially failures." source: ["project-templates-toolkit/skills/project-retrospective/SKILL.md", ".claude-plugin/marketplace.json"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["skill", "project-templates-toolkit"] --- # project-retrospective skill Generate LESSONS.md retrospective files that capture institutional knowledge, especially failures. Use when closing out journalism projects, investigations, events, or publications. Includes templates for research projects, event post-mortems, editorial tools, and publications. Part of the [project-templates-toolkit plugin](../plugins/project-templates-toolkit.md). Source: `project-templates-toolkit/skills/project-retrospective/SKILL.md`. -
python-pipeline.md 737 B
--- type: Reference title: "python-pipeline skill" description: "Python data processing pipelines with modular architecture." source: ["dev-toolkit/skills/python-pipeline/SKILL.md", ".claude-plugin/marketplace.json"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["skill", "dev-toolkit"] --- # python-pipeline skill Python data processing pipelines with modular architecture. Use when building content processing workflows, implementing dispatcher patterns, integrating Google Sheets/Drive APIs, or creating batch processing systems. Covers patterns from rosen-scraper, image- analyzer, and social-scraper projects. Part of the [dev-toolkit plugin](../plugins/dev-toolkit.md). Source: `dev-toolkit/skills/python-pipeline/SKILL.md`. -
receiving-code-review.md 784 B
--- type: Reference title: "receiving-code-review skill" description: "Use when receiving code review feedback, before implementing suggestions, especially if feedback seems unclear or technically questionable - requires tech…" source: ["superjawn/skills/receiving-code-review/SKILL.md", ".claude-plugin/marketplace.json"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["skill", "superjawn"] --- # receiving-code-review skill Use when receiving code review feedback, before implementing suggestions, especially if feedback seems unclear or technically questionable - requires technical rigor and verification, not performative agreement or blind implementation Part of the [superjawn plugin](../plugins/superjawn.md). Source: `superjawn/skills/receiving-code-review/SKILL.md`. -
requesting-code-review.md 611 B
--- type: Reference title: "requesting-code-review skill" description: "Use when completing tasks, implementing major features, or before merging to verify work meets requirements" source: ["superjawn/skills/requesting-code-review/SKILL.md", ".claude-plugin/marketplace.json"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["skill", "superjawn"] --- # requesting-code-review skill Use when completing tasks, implementing major features, or before merging to verify work meets requirements Part of the [superjawn plugin](../plugins/superjawn.md). Source: `superjawn/skills/requesting-code-review/SKILL.md`. -
secure-auth.md 757 B
--- type: Reference title: "secure-auth skill" description: "Secure authentication implementation patterns." source: ["security-toolkit/skills/secure-auth/SKILL.md", ".claude-plugin/marketplace.json"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["skill", "security-toolkit"] --- # secure-auth skill Secure authentication implementation patterns. Use when implementing user login, registration, password reset, session management, JWT authentication, OAuth, MFA, or passkeys. Provides production-ready patterns aligned with NIST SP 800-63B-4, OWASP 2026 cheat sheets, OAuth 2.1, and WebAuthn L3, with breach-driven lessons. Part of the [security-toolkit plugin](../plugins/security-toolkit.md). Source: `security-toolkit/skills/secure-auth/SKILL.md`. -
security-checklist.md 916 B
--- type: Reference title: "security-checklist skill" description: "Pre-deployment security audit for web applications, organized by OWASP Top 10:2025 categories." source: ["security-toolkit/skills/security-checklist/SKILL.md", ".claude-plugin/marketplace.json"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["skill", "security-toolkit"] --- # security-checklist skill Pre-deployment security audit for web applications, organized by OWASP Top 10:2025 categories. Use when reviewing code before shipping, auditing an existing application, or when users mention "security review," "ready to deploy," "going to production," or express concern about vulnerabilities. Covers access control, supply chain, cryptography, injection, auth, integrity, logging, and exception handling. Part of the [security-toolkit plugin](../plugins/security-toolkit.md). Source: `security-toolkit/skills/security-checklist/SKILL.md`. -
social-media-intelligence.md 896 B
--- type: Reference title: "social-media-intelligence skill" description: "Social media monitoring, narrative tracking, and open-source intelligence for journalists." source: ["journalism-core/skills/social-media-intelligence/SKILL.md", ".claude-plugin/marketplace.json"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["skill", "journalism-core"] --- # social-media-intelligence skill Social media monitoring, narrative tracking, and open-source intelligence for journalists. Use when tracking viral content spread, analyzing coordinated campaigns, monitoring breaking news on social platforms, investigating accounts for authenticity, or detecting misinformation patterns. Essential for reporters covering online narratives and digital investigations. Part of the [journalism-core plugin](../plugins/journalism-core.md). Source: `journalism-core/skills/social-media-intelligence/SKILL.md`. -
source-verification.md 799 B
--- type: Reference title: "source-verification skill" description: "Journalism source verification and fact-checking workflows." source: ["journalism-core/skills/source-verification/SKILL.md", ".claude-plugin/marketplace.json"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["skill", "journalism-core"] --- # source-verification skill Journalism source verification and fact-checking workflows. Use when verifying claims, checking source credibility, investigating social media accounts, reverse image searching, detecting AI-generated content, or building verification trails. For reporters, fact-checkers, and researchers working with unverified information. Part of the [journalism-core plugin](../plugins/journalism-core.md). Source: `journalism-core/skills/source-verification/SKILL.md`. -
story-pitch.md 706 B
--- type: Reference title: "story-pitch skill" description: "Craft effective story pitches for different publication types and formats." source: ["journalism-core/skills/story-pitch/SKILL.md", ".claude-plugin/marketplace.json"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["skill", "journalism-core"] --- # story-pitch skill Craft effective story pitches for different publication types and formats. Use when pitching to editors, preparing query letters, or developing story angles. Includes templates for daily news, features, investigations, op-eds, and freelance queries. Part of the [journalism-core plugin](../plugins/journalism-core.md). Source: `journalism-core/skills/story-pitch/SKILL.md`. -
subagent-driven-development.md 587 B
--- type: Reference title: "subagent-driven-development skill" description: "Use when executing implementation plans with independent tasks in the current session" source: ["superjawn/skills/subagent-driven-development/SKILL.md", ".claude-plugin/marketplace.json"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["skill", "superjawn"] --- # subagent-driven-development skill Use when executing implementation plans with independent tasks in the current session Part of the [superjawn plugin](../plugins/superjawn.md). Source: `superjawn/skills/subagent-driven-development/SKILL.md`. -
supply-chain-hardening.md 1005 B
--- type: Reference title: "supply-chain-hardening skill" description: "Configure install-time cooldowns for npm/bun (minimum release age) and run a sandboxed pre-install scan when the cooldown has to be bypassed." source: ["security-toolkit/skills/supply-chain-hardening/SKILL.md", ".claude-plugin/marketplace.json"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["skill", "security-toolkit"] --- # supply-chain-hardening skill Configure install-time cooldowns for npm/bun (minimum release age) and run a sandboxed pre-install scan when the cooldown has to be bypassed. Use when the user asks about supply-chain attacks, npm/bun security, "minimum release age", a "cooldown" for installs, hardening against Shai-Hulud-class worms, or how to safely install a package that was just published. Also use after any recent supply-chain incident in the npm ecosystem. Part of the [security-toolkit plugin](../plugins/security-toolkit.md). Source: `security-toolkit/skills/supply-chain-hardening/SKILL.md`. -
systematic-debugging.md 571 B
--- type: Reference title: "systematic-debugging skill" description: "Use when encountering any bug, test failure, or unexpected behavior, before proposing fixes" source: ["superjawn/skills/systematic-debugging/SKILL.md", ".claude-plugin/marketplace.json"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["skill", "superjawn"] --- # systematic-debugging skill Use when encountering any bug, test failure, or unexpected behavior, before proposing fixes Part of the [superjawn plugin](../plugins/superjawn.md). Source: `superjawn/skills/systematic-debugging/SKILL.md`. -
template-selector.md 801 B
--- type: Reference title: "template-selector skill" description: "Choose the correct CLAUDE.md or LESSONS.md template for journalism projects." source: ["project-templates-toolkit/skills/template-selector/SKILL.md", ".claude-plugin/marketplace.json"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["skill", "project-templates-toolkit"] --- # template-selector skill Choose the correct CLAUDE.md or LESSONS.md template for journalism projects. Use when starting a new project, setting up documentation, or unsure which template category fits best. Provides decision trees and selection guidance for 6 journalism-focused template types. Part of the [project-templates-toolkit plugin](../plugins/project-templates-toolkit.md). Source: `project-templates-toolkit/skills/template-selector/SKILL.md`. -
test-driven-development.md 559 B
--- type: Reference title: "test-driven-development skill" description: "Use when implementing any feature or bugfix, before writing implementation code" source: ["superjawn/skills/test-driven-development/SKILL.md", ".claude-plugin/marketplace.json"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["skill", "superjawn"] --- # test-driven-development skill Use when implementing any feature or bugfix, before writing implementation code Part of the [superjawn plugin](../plugins/superjawn.md). Source: `superjawn/skills/test-driven-development/SKILL.md`. -
test-first-bugs.md 804 B
--- type: Reference title: "test-first-bugs skill" description: "This skill should be used when the user reports a bug, describes unexpected behavior, says something is \"broken\", \"not working\", \"failing\", mentions an \"e…" source: ["dev-toolkit/skills/test-first-bugs/SKILL.md", ".claude-plugin/marketplace.json"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["skill", "dev-toolkit"] --- # test-first-bugs skill This skill should be used when the user reports a bug, describes unexpected behavior, says something is "broken", "not working", "failing", mentions an "error", "issue", or "problem" in code, or asks to "fix" something. Enforces test-driven bug fixing workflow. Part of the [dev-toolkit plugin](../plugins/dev-toolkit.md). Source: `dev-toolkit/skills/test-first-bugs/SKILL.md`. -
using-git-worktrees.md 744 B
--- type: Reference title: "using-git-worktrees skill" description: "Use when starting feature work that needs isolation from current workspace or before executing implementation plans - creates isolated git worktrees with…" source: ["superjawn/skills/using-git-worktrees/SKILL.md", ".claude-plugin/marketplace.json"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["skill", "superjawn"] --- # using-git-worktrees skill Use when starting feature work that needs isolation from current workspace or before executing implementation plans - creates isolated git worktrees with smart directory selection and safety verification Part of the [superjawn plugin](../plugins/superjawn.md). Source: `superjawn/skills/using-git-worktrees/SKILL.md`. -
using-superjawn.md 685 B
--- type: Reference title: "using-superjawn skill" description: "Use when starting any conversation - establishes how to find and use skills, requiring Skill tool invocation before ANY response including clarifying ques…" source: ["superjawn/skills/using-superjawn/SKILL.md", ".claude-plugin/marketplace.json"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["skill", "superjawn"] --- # using-superjawn skill Use when starting any conversation - establishes how to find and use skills, requiring Skill tool invocation before ANY response including clarifying questions Part of the [superjawn plugin](../plugins/superjawn.md). Source: `superjawn/skills/using-superjawn/SKILL.md`. -
verification-before-completion.md 811 B
--- type: Reference title: "verification-before-completion skill" description: "Use when about to claim work is complete, fixed, or passing, before committing or creating PRs - requires running verification commands and confirming out…" source: ["superjawn/skills/verification-before-completion/SKILL.md", ".claude-plugin/marketplace.json"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["skill", "superjawn"] --- # verification-before-completion skill Use when about to claim work is complete, fixed, or passing, before committing or creating PRs - requires running verification commands and confirming output before making any success claims; evidence before assertions always Part of the [superjawn plugin](../plugins/superjawn.md). Source: `superjawn/skills/verification-before-completion/SKILL.md`. -
vibe-coding.md 906 B
--- type: Reference title: "vibe-coding skill" description: "Methodology for effective AI-assisted software development." source: ["dev-toolkit/skills/vibe-coding/SKILL.md", ".claude-plugin/marketplace.json"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["skill", "dev-toolkit"] --- # vibe-coding skill Methodology for effective AI-assisted software development. Use when helping users build software with AI coding assistants, debugging AI-generated code, planning features for AI implementation, managing version control in AI workflows, or when users mention "vibe coding," Claude Code, Cursor, GitHub Copilot, Aider, Continue, Cline, Codex, Windsurf, or similar AI coding tools. Provides strategies for planning, testing, debugging, and iterating on code written with LLM assistance. Part of the [dev-toolkit plugin](../plugins/dev-toolkit.md). Source: `dev-toolkit/skills/vibe-coding/SKILL.md`. -
video-dashboard.md 546 B
--- type: Reference title: "video-dashboard skill" description: "Aggregate transcript and frame analysis into an interactive dashboard." source: ["video-toolkit/skills/video-dashboard/SKILL.md", ".claude-plugin/marketplace.json"] verified: 2026-08-15 timestamp: 2026-08-15 tags: ["skill", "video-toolkit"] --- # video-dashboard skill Build a local interactive dashboard from structured transcript and frame analysis data. Part of the [video-toolkit plugin](../plugins/video-toolkit.md). Source: `video-toolkit/skills/video-dashboard/SKILL.md`. -
video-download.md 587 B
--- type: Reference title: "video-download skill" description: "Collect authorized public social video for reporting and analysis." source: ["video-toolkit/skills/video-download/SKILL.md", ".claude-plugin/marketplace.json"] verified: 2026-08-15 timestamp: 2026-08-15 tags: ["skill", "video-toolkit"] --- # video-download skill Download authorized public social video with yt-dlp and a bounded browser fallback. Treat remote content and metadata as untrusted data. Part of the [video-toolkit plugin](../plugins/video-toolkit.md). Source: `video-toolkit/skills/video-download/SKILL.md`. -
video-frames.md 537 B
--- type: Reference title: "video-frames skill" description: "Extract and analyze representative frames from video files." source: ["video-toolkit/skills/video-frames/SKILL.md", ".claude-plugin/marketplace.json"] verified: 2026-08-15 timestamp: 2026-08-15 tags: ["skill", "video-toolkit"] --- # video-frames skill Extract frames, create review grids, and catalog on-screen text, settings, and other visual evidence. Part of the [video-toolkit plugin](../plugins/video-toolkit.md). Source: `video-toolkit/skills/video-frames/SKILL.md`. -
video-transcribe.md 578 B
--- type: Reference title: "video-transcribe skill" description: "Transcribe video with a re-runnable provenance record." source: ["video-toolkit/skills/video-transcribe/SKILL.md", ".claude-plugin/marketplace.json"] verified: 2026-08-15 timestamp: 2026-08-15 tags: ["skill", "video-toolkit"] --- # video-transcribe skill Transcribe video files and record the source hash, engine, model, and decode settings needed to trace quotations back to the media. Part of the [video-toolkit plugin](../plugins/video-toolkit.md). Source: `video-toolkit/skills/video-transcribe/SKILL.md`. -
visual-explainer.md 1 KB
--- type: Reference title: "visual-explainer skill" description: "Generate self-contained HTML pages that visually explain systems, data stories, investigations, editorial workflows, and code changes." source: ["visual-explainer/SKILL.md", ".claude-plugin/marketplace.json"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["skill", "visual-explainer"] --- # visual-explainer skill Generate self-contained HTML pages that visually explain systems, data stories, investigations, editorial workflows, and code changes. Use when the user asks for a diagram, architecture overview, diff review, plan review, project recap, source map, comparison table, timeline, or any visual explanation of technical or editorial concepts. Also use proactively when about to render a complex ASCII table (4+ rows or 3+ columns), present it as a styled HTML page instead. Adapted from nicobailon/visual- explainer with journalism, newsroom, and academic design sensibilities. Part of the [visual-explainer plugin](../plugins/visual-explainer.md). Source: `visual-explainer/SKILL.md`. -
web-archiving.md 740 B
--- type: Reference title: "web-archiving skill" description: "Web page archiving and retrieval from cached/deleted sources." source: ["research-toolkit/skills/web-archiving/SKILL.md", ".claude-plugin/marketplace.json"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["skill", "research-toolkit"] --- # web-archiving skill Web page archiving and retrieval from cached/deleted sources. Use when accessing unavailable pages, preserving web content, creating legal evidence archives, or building redundant archival workflows. Covers Wayback Machine, Archive.today, ArchiveBox, and evidence preservation tools. Part of the [research-toolkit plugin](../plugins/research-toolkit.md). Source: `research-toolkit/skills/web-archiving/SKILL.md`. -
web-scraping.md 779 B
--- type: Reference title: "web-scraping skill" description: "Web scraping with anti-bot bypass, content extraction, undocumented APIs and poison pill detection." source: ["dev-toolkit/skills/web-scraping/SKILL.md", ".claude-plugin/marketplace.json"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["skill", "dev-toolkit"] --- # web-scraping skill Web scraping with anti-bot bypass, content extraction, undocumented APIs and poison pill detection. Use when extracting content from websites, handling paywalls, implementing scraping cascades or processing social media. Covers requests, trafilatura, Playwright with stealth mode, yt-dlp and instaloader patterns. Part of the [dev-toolkit plugin](../plugins/dev-toolkit.md). Source: `dev-toolkit/skills/web-scraping/SKILL.md`. -
web-ui-best-practices.md 718 B
--- type: Reference title: "web-ui-best-practices skill" description: "Signs of taste in web UI." source: ["dev-toolkit/skills/web-ui-best-practices/SKILL.md", ".claude-plugin/marketplace.json"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["skill", "dev-toolkit"] --- # web-ui-best-practices skill Signs of taste in web UI. Use when building or reviewing any user-facing web interface, dashboards, SaaS apps, marketing sites, internal tools. Covers interaction speed, navigation depth, visual restraint, copy quality, and the small details that separate polished products from rough ones. Part of the [dev-toolkit plugin](../plugins/dev-toolkit.md). Source: `dev-toolkit/skills/web-ui-best-practices/SKILL.md`. -
writing-plans.md 529 B
--- type: Reference title: "writing-plans skill" description: "Use when you have a spec or requirements for a multi-step task, before touching code" source: ["superjawn/skills/writing-plans/SKILL.md", ".claude-plugin/marketplace.json"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["skill", "superjawn"] --- # writing-plans skill Use when you have a spec or requirements for a multi-step task, before touching code Part of the [superjawn plugin](../plugins/superjawn.md). Source: `superjawn/skills/writing-plans/SKILL.md`. -
writing-skills.md 559 B
--- type: Reference title: "writing-skills skill" description: "Use when creating new skills, editing existing skills, or verifying skills work before deployment" source: ["superjawn/skills/writing-skills/SKILL.md", ".claude-plugin/marketplace.json"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["skill", "superjawn"] --- # writing-skills skill Use when creating new skills, editing existing skills, or verifying skills work before deployment Part of the [superjawn plugin](../plugins/superjawn.md). Source: `superjawn/skills/writing-skills/SKILL.md`. -
zero-build-frontend.md 800 B
--- type: Reference title: "zero-build-frontend skill" description: "Zero-build frontend development with CDN-loaded React, Tailwind CSS, and vanilla JavaScript." source: ["dev-toolkit/skills/zero-build-frontend/SKILL.md", ".claude-plugin/marketplace.json"] verified: 2026-06-26 timestamp: 2026-06-26 tags: ["skill", "dev-toolkit"] --- # zero-build-frontend skill Zero-build frontend development with CDN-loaded React, Tailwind CSS, and vanilla JavaScript. Use when building static web apps without bundlers, creating Leaflet maps, integrating Google Sheets as database, or developing browser extensions. Covers patterns from rosen-frontend, NJCIC map, and PocketLink projects. Part of the [dev-toolkit plugin](../plugins/dev-toolkit.md). Source: `dev-toolkit/skills/zero-build-frontend/SKILL.md`.
-
-
systems
-
contributing.md 1.2 KB
--- type: Process title: "contributing a skill" description: "How to add a skill, hook, or plugin: directory layout, SKILL.md frontmatter, and the house style." source: ["CONTRIBUTING.md", "CLAUDE.md"] verified: 2026-06-26 timestamp: 2026-06-26 tags: [contributing, authoring] --- # contributing a skill A skill is a directory with a `SKILL.md` carrying `name` and `description` frontmatter, plus optional `templates/`, `examples/`, and `scripts/`. The `description` is what Claude matches on to activate the skill, so it should name specific trigger conditions. Skills now live inside a plugin at `<plugin>/skills/<skill-name>/SKILL.md`; single-skill plugins keep `SKILL.md` at the plugin root. A hook is a single markdown file in `hooks/` with `event` (and `tools` for Pre/PostToolUse hooks) frontmatter, see [the hooks catalog](hooks-catalog.md). House style, enforced by review and the writing hooks: sentence-case headings, terse and actionable descriptions, cited sources, and no AI writing patterns (the `ai-writing-detox` skill lists the banned words). After adding a skill, register its plugin in [the marketplace](marketplace.md) and update the docs site. Validate any OKF wiki changes with [the OKF format](okf-format.md) validator and the [tests](testing.md). -
hooks-catalog.md 1.3 KB
--- type: Reference title: "the hooks catalog" description: "Seventeen standalone workflow hooks under hooks/, grouped by purpose; three block intentionally." source: ["hooks/", "CLAUDE.md"] verified: 2026-06-30 timestamp: 2026-06-30 tags: [hooks, catalog] --- # the hooks catalog The `hooks/` directory holds 17 single-file workflow hooks, each a markdown instruction set with frontmatter naming its `event` and (for tool hooks) its `tools`. They run automatically at workflow events, writing, editing, submitting a prompt, stopping, or session start. Most are **non-blocking warnings**: they surface guidance without stopping the action. Three block intentionally, `one-way-door-check` (a shell hook that exits 2 on an irreversible-decision file until confirmed), `enforce-test-first` (gates source edits until a failing test exists), and `no-ai-attribution` (denies a commit, PR, or comment that carries AI authorship credit). `pre-commit-review` surfaces the staged diff before a commit. They group into five purposes: writing quality, verification, editorial workflow, preservation, and development. Browse every hook in the [hooks section](../hooks/index.md). These standalone hooks are separate from the OKF [session-start hooks](session-hooks.md) the okf-wiki scaffolder writes. -
index.md 378 B
# systems How this repository is structured, validated, and published. - [repo overview](repo-overview.md) - [the OKF format](okf-format.md) - [session-start hooks](session-hooks.md) - [the marketplace](marketplace.md) - [the docs site and deploy](pages-deploy.md) - [the hooks catalog](hooks-catalog.md) - [tests and CI](testing.md) - [contributing a skill](contributing.md) -
marketplace.md 654 B
--- type: Reference title: "the marketplace" description: "How plugins are registered and installed via the marketplace manifest." source: [".claude-plugin/marketplace.json", "README.md"] verified: 2026-06-23 timestamp: 2026-06-23 tags: [marketplace, install] --- # the marketplace `.claude-plugin/marketplace.json` registers every plugin with its name, version, and source path. Add the marketplace once, then install a plugin: ``` /plugin marketplace add jamditis/claude-skills-journalism /plugin install okf-wiki@claude-skills-journalism ``` Each plugin also carries its own `.claude-plugin/plugin.json`. Browse the [plugins](../plugins/index.md). -
okf-format.md 693 B
--- type: Reference title: "the OKF format" description: "Open Knowledge Format: one concept per file, provenance in frontmatter, a validator." source: ["okf-wiki/spec/SPEC.md", "okf-wiki/scripts/validate.py"] verified: 2026-06-23 timestamp: 2026-06-23 tags: [okf, format] --- # the OKF format OKF stores knowledge as small markdown files, one concept each, with provenance in YAML frontmatter (`type, title, description, source, verified, timestamp, tags`). Directory `index.md` files navigate; the bundle-root `index.md` carries `okf_version` only. A validator enforces the contract and scans for leaked secrets. The [okf-wiki plugin](../plugins/okf-wiki.md) scaffolds a conforming bundle. -
pages-deploy.md 616 B
--- type: Process title: "the docs site and deploy" description: "The docs/ directory publishes to skills.amditis.tech via GitHub Pages on merge." source: ["docs/index.html", "docs/okf-wiki/index.html"] verified: 2026-06-23 timestamp: 2026-06-23 tags: [docs, deploy] --- # the docs site and deploy The `docs/` directory is the source for the GitHub Pages site at skills.amditis.tech. The landing page lists the plugins; each plugin gets a page under its own slug (for example `docs/okf-wiki/`). Merging to the default branch rebuilds and publishes the site. Plugins are listed in [the marketplace](marketplace.md). -
repo-overview.md 694 B
--- type: Repo title: "claude-skills-journalism" description: "A Claude Code plugin marketplace: 12 plugins, 63 skills, 17 hooks." source: ["README.md", "CLAUDE.md", ".claude-plugin/marketplace.json"] verified: 2026-08-21 timestamp: 2026-08-21 tags: [repo, overview] --- # claude-skills-journalism A marketplace of Claude Code plugins for journalism, research, media, and technical work. It ships 12 plugins totaling 63 skills, plus 17 standalone hooks under `hooks/`. Plugins are registered in [the marketplace](marketplace.md) and documented at [the docs site](pages-deploy.md). Browse the [plugins](../plugins/index.md), the [skills](../skills/index.md), or the [hooks](../hooks/index.md). -
session-hooks.md 729 B
--- type: Reference title: "session-start hooks" description: "Two hooks that orient Claude on an OKF bundle before it works." source: ["okf-wiki/templates/hooks/okf-anchor.py", "okf-wiki/templates/hooks/okf-orient.py"] verified: 2026-06-23 timestamp: 2026-06-23 tags: [okf, hooks] --- # session-start hooks A scaffolded bundle ships two hooks. `okf-anchor.py` (SessionStart) loads the bundle index into the session context. `okf-orient.py` (PreToolUse) blocks the first action once per session until Claude confirms it has read the index, then unblocks. Both are one cross-platform python3 script; only the launch command in `.claude/settings.json` differs per OS. They belong to the [okf-wiki plugin](../plugins/okf-wiki.md). -
testing.md 1.3 KB
--- type: Process title: "tests and CI" description: "pytest covers the okf-wiki scaffolder and validator; three GitHub Actions workflows gate pull requests." source: ["okf-wiki/tests/test_okf_wiki.py", ".github/workflows/okf-wiki-tests.yml", ".github/workflows/skill-lint.yml", ".github/workflows/security-toolkit-hotpatch-selftest.yml"] verified: 2026-06-26 timestamp: 2026-06-26 tags: [tests, ci] --- # tests and CI `okf-wiki/tests/test_okf_wiki.py` exercises the scaffolder and the validator as a user would, running the real CLI scripts in temp directories. One test validates this committed example bundle, so a stale or broken wiki cannot merge. Run it with `python3 -m pytest okf-wiki/tests/ -q`. Three GitHub Actions workflows gate pull requests by path: - `okf-wiki-tests.yml`, runs the pytest suite when anything under `okf-wiki/**` changes (so edits to this bundle are validated in CI, via [the OKF format](okf-format.md) validator). - `skill-lint.yml`, lints `*/SKILL.md` and `hooks/*.md` frontmatter and structure. - `security-toolkit-hotpatch-selftest.yml`, self-tests the supply-chain hotpatch scanner against synthetic malicious fixtures. The repository also follows a test-first bug-fixing rule documented in `CLAUDE.md`: reproduce a bug with a failing test before fixing it.
-
-
index.md 899 B
--- okf_version: "0.3" --- # claude-skills-journalism wiki An Open Knowledge Format wiki of this repository, scaffolded by the `okf-wiki` skill that lives in it. Every concept points its `source` at the real files it describes, so you can diff the wiki against the code. This is the working example linked from the okf-wiki page. The repository is a Claude Code plugin marketplace: 12 plugins, 63 skills, and 17 standalone hooks. Start at the [repo overview](systems/repo-overview.md), browse by [plugin](plugins/index.md), or jump straight to a [skill](skills/index.md). ## Sections - [plugins](plugins/index.md) - the 12 plugins this marketplace ships - [skills](skills/index.md) - all 63 skills, one concept per file, grouped by plugin - [hooks](hooks/index.md) - the 17 standalone workflow hooks under `hooks/` - [systems](systems/index.md) - how the repo is built, validated, and published
-
-
scripts
-
validate.py 61 KB
#!/usr/bin/env python3 """Validate an OKF (Open Knowledge Format) bundle against OKF spec v1 (see SPEC.md). An OKF bundle is a tree of small markdown files: one concept per file, each with YAML frontmatter carrying its provenance. Directory `index.md` files provide navigation. This validator enforces the contract so a bundle stays machine- and agent-readable. Checks: 1. Every non-reserved .md file has a parseable YAML frontmatter block. A YAML parse error is reported (commonly an unquoted colon-space or '#' in a string field, quote the value). 2. Frontmatter carries every required key, non-empty. Through okf_version 0.3: type, title, description, source, verified, timestamp, tags. At 0.4: the same list with 'verified' renamed to 'verified_on' (see #6). - type is one of the spec type vocab. - source is a non-empty list of non-empty strings (provenance pointers). An unquoted '#' in a block-style element (which YAML would silently drop as a comment, losing the rest) is rejected, quote it. - tags is a list. - verified/verified_on parses as an ISO date (YYYY-MM-DD). - timestamp parses as an ISO date, or under okf_version 0.3+ as a full ISO 8601 datetime (upstream OKF writes a datetime). 3. Reserved filenames (index.md, log.md) name no concept and carry no frontmatter, except the bundle-root index.md may carry okf_version only. 4. Internal markdown links resolve. Links must be relative, a root-relative ('/'-prefixed) link is rejected. Every link to a .md file inside the bundle must point at a file that exists, with the case it has on disk (a case-insensitive filesystem would otherwise let a wrong-case link pass on macOS or Windows and dangle on Linux); a link that escapes the bundle root or dangles is a hard failure. Optional link titles and <>-wrapped destinations are handled. The bundle is validated as one self-contained tree (to validate federated content, assemble the bundles into one tree and point --bundle at that root). 5. No file leaks a secret VALUE (private-key blocks, cloud API tokens, secret=<blob> assignments). Credential concepts document key NAMES and paths, never the values. Heuristic; narrow a pattern if it false-positives, do not delete the rule. 6. At okf_version 0.4, optional upstream-v0.2 trust/provenance fields are checked for shape when present (never required): 'generated' ({by, at}); the new 'verified' (a list of {by, at} confirmations, distinct from the required 'verified_on' at that version); 'sources' (plural; structured provenance objects, distinct from the required singular 'source'); 'status' (draft/stable/deprecated); 'stale_after' (an ISO date). A concept typed 'Attested Computation' additionally requires 'runtime', 'parameters', 'executor', and 'attester' to be present and correctly shaped. Exits non-zero on any hard failure. Usage: python3 validate.py --bundle DIR """ from __future__ import annotations import argparse import datetime as dt import math import os import re import sys from collections import Counter from pathlib import Path, PureWindowsPath import yaml REQUIRED_KEYS_LEGACY = ("type", "title", "description", "source", "verified", "timestamp", "tags") # At TRUST_SIGNALS_VERSION, 'verified' is renamed to 'verified_on' in the required # set -- it frees the bare name 'verified' for upstream v0.2's own optional field (a # list of {by, at} confirmations; see check_verified_trust), which is a different # shape and would otherwise collide with this fork's older single-date field. REQUIRED_KEYS_V04 = ("type", "title", "description", "source", "verified_on", "timestamp", "tags") LIST_KEYS = ("source", "tags") # Keys that may also carry a full ISO 8601 datetime (see check_dates). The # bundle must explicitly opt into this grammar through okf_version 0.3 so an # older validator rejects the format at its version gate instead of later on a # timestamp it does not understand. DATETIME_KEYS = ("timestamp",) DATETIME_TIMESTAMP_VERSION = "0.3" # The version at which 'verified' is renamed to 'verified_on' and the optional # upstream-v0.2 trust/provenance fields (generated, verified, sources, status, # stale_after, Attested Computation) become available. See REQUIRED_KEYS_V04 above # and SPEC.md's "Trust and provenance (upstream v0.2 vocabulary)" section. TRUST_SIGNALS_VERSION = "0.4" LEGACY_ALLOWED_TYPES = { # Infrastructure / ops (fleet maps, system docs) "Machine", "Network", "Service", "Session", "Project", "Repo", "Credential", "Path", "Process", # Domain-neutral (newsrooms, research atlases, decision logs) "Concept", "Decision", "Event", "Person", "Org", "Source", # Catch-all "Reference", } TRUST_SIGNAL_TYPES = { # Upstream v0.2: a sanctioned computation plus how to check a run of it # (see check_attested_computation). Kept as the literal upstream spelling # (with a space), not renamed to fit a no-space convention. "Attested Computation", } ALLOWED_TYPES = LEGACY_ALLOWED_TYPES | TRUST_SIGNAL_TYPES ALLOWED_STATUSES = {"draft", "stable", "deprecated"} RESERVED = {"index.md", "log.md"} # okf_version values this validator accepts. The last entry is the current format # version, but it is opt-in, not the scaffold default -- scaffold.py still writes # "0.3" unless --trust-signals asks for "0.4" (see scaffold.py's own default). # Older entries stay supported so a newer validator still reads an older bundle. # Adding allowed types is backward compatible and bumps the format version # (0.1 -> 0.2). Accepting a datetime in timestamp changes the field grammar and # bumps it again (0.2 -> 0.3). Renaming 'verified' to 'verified_on' and adopting # the optional upstream-v0.2 trust fields bumps it once more (0.3 -> 0.4). SUPPORTED_VERSIONS = ("0.1", "0.2", DATETIME_TIMESTAMP_VERSION, TRUST_SIGNALS_VERSION) SPEC_VERSION = SUPPORTED_VERSIONS[-1] # current (opt-in) format version -- see note above def required_keys_for(bundle_version): """The required-key tuple for a declared okf_version. Only TRUST_SIGNALS_VERSION ("0.4") renames 'verified' to 'verified_on'; every other declared (or missing/unsupported) version keeps the legacy name, so an unsupported-version bundle is still checked against a sensible required set instead of crashing before the version-gate error is reported. """ return REQUIRED_KEYS_V04 if bundle_version == TRUST_SIGNALS_VERSION else REQUIRED_KEYS_LEGACY def allowed_types_for(bundle_version): """The closed type vocabulary for a declared okf_version.""" return ALLOWED_TYPES if bundle_version == TRUST_SIGNALS_VERSION else LEGACY_ALLOWED_TYPES def date_keys_for(bundle_version): """The date-checked key names for a declared okf_version (see required_keys_for).""" return ("verified_on", "timestamp") if bundle_version == TRUST_SIGNALS_VERSION else ("verified", "timestamp") def supports_datetime_timestamp(bundle_version): """Whether a supported bundle version carries full timestamp precision.""" try: return ( SUPPORTED_VERSIONS.index(bundle_version) >= SUPPORTED_VERSIONS.index(DATETIME_TIMESTAMP_VERSION) ) except ValueError: return False # Inline markdown link. The destination group allows one level of balanced # parens so a filename like `missing(v2).md` is still captured (a plain [^)]+ # would stop at the first ')' and skip the link entirely). LINK_RE = re.compile(r"\[[^\]]*\]\(((?:[^()]|\([^()]*\))*)\)") # OKF links are relative markdown links only. The [[slug]] wikilink idiom (from the # auto-memory system) is not OKF, so a typo'd or deleted [[ref]] would otherwise pass # the link check unseen. It is reported as an error. Checked against strip_code output, # so a [[x]] shown inside a code fence is illustrative, not flagged. WIKILINK_RE = re.compile(r"\[\[([^\[\]]+)\]\]") # Secret-value detectors. These match credential VALUES, not the key names/paths # a credential concept is allowed to document. The generic assignment pattern # requires a separator (`:`/`=`) directly before a high-entropy blob, so a # documented key name like `service/api/...-secret` does not trip it. # # The key labels that mark a value as a credential, shared by the generic base64 # assignment pattern and the opt-in entropy scan so the two agree on what counts # as a labeled secret. SECRET_LABEL = ( r"(?:password|passwd|secret|api[_-]?key|apikey|client[_-]?secret" r"|access[_-]?token|auth[_-]?token)") SECRET_PATTERNS = [ ("Tailscale key", re.compile(r"tskey-(?:api|auth|client)-[A-Za-z0-9]+-[A-Za-z0-9]{10,}")), ("private-key block", re.compile(r"-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----")), ("AWS access key id", re.compile(r"\bAKIA[0-9A-Z]{16}\b")), ("Google API key", re.compile(r"\bAIza[0-9A-Za-z_\-]{35}\b")), ("Slack token", re.compile(r"\bxox[baprs]-[0-9A-Za-z-]{10,}")), ("GitHub token", re.compile(r"\bgh[pousr]_[0-9A-Za-z]{36,}\b")), ("GitHub fine-grained PAT", re.compile(r"\bgithub_pat_[0-9A-Za-z_]{22,}\b")), # More provider tokens with fixed literal prefixes and exact or narrow shapes. # Providers whose token bodies allow path-like hyphens and underscores use the # entropy-gated patterns below instead. ("Stripe secret key", re.compile(r"\b[sr]k_(?:live|test)_[0-9A-Za-z]{24,}\b")), ("Stripe organization key", re.compile(r"\bsk_org_[0-9A-Za-z]{24,}\b")), ("Stripe webhook secret", re.compile(r"\bwhsec_[0-9A-Za-z]{32,}\b")), # GitLab documents this exact cookie label as a token prefix. Keep the value # shape narrow enough that its `_gitlab_session=...` documentation placeholder # stays clean while an actual serialized cookie is caught. ("GitLab session cookie", re.compile( r"(?i)\b_gitlab_session\s*=\s*['\"]?" r"[0-9A-Za-z%+/_=-]{20,}(?![0-9A-Za-z%+/_=.\-])")), ("npm token", re.compile(r"\bnpm_[0-9A-Za-z]{36}\b")), ("SendGrid API key", re.compile(r"\bSG\.[0-9A-Za-z_-]{22}\.[0-9A-Za-z_-]{43}")), # Legacy personal OpenAI keys carry no project/service segment. `sk-` alone is a # weak prefix, so the 40-char solid-base62 run does the signal work; it stays # disjoint from the entropy-gated project-key detector below, whose `-` breaks # the run. ("OpenAI legacy key", re.compile(r"\bsk-[0-9A-Za-z]{40,}\b")), ("secret assignment", re.compile( r"(?i)" + SECRET_LABEL + # Base64-standard value charset only -- deliberately excludes - and _. A # credential concept documents key paths like `secret: svc/api/prod-key-path`, # and a hyphen/underscore-rich path must not read as a high-entropy value. # Structured tokens that use -/_ (fine-grained PATs, Slack, etc.) have their # own specific patterns above; the opt-in entropy scan below covers the rest. r"\s*[:=]\s*['\"]?[A-Za-z0-9+/]{24,}['\"]?")), ] # Some provider token bodies allow the same hyphens and underscores used in # human-readable vault paths. A prefix alone would therefore flag documentation # such as `openai/sk-proj-production-primary-key-path`. These patterns capture a # complete base64url-like body. They still run by default because the provider # prefix is strong, but the entropy gate keeps path documentation clean. OpenAI # and Anthropic use the generic 4.0 bits/character floor. GitLab accepts 20-char # bodies, whose repeated characters lower their observable entropy; its narrower # floor is 88% of the maximum entropy observable at the captured body length, # capped at 4.0. That is 3.80 bits/character at 20 chars and rises to 4.0 at 24, # so short tokens are not judged against a longer sample's ceiling while the # longer path regressions stay clean. # # GitLab prefixes are from its token overview (checked 2026-07-23): # https://docs.gitlab.com/security/tokens/#token-prefixes PREFIXED_SECRET_PATTERNS = [ ("GitLab token", re.compile( r"\b(?:glpat|gloas|gldt|glrtr?|glcbt|glptt|glft|glimt|glagent|glwt" r"|glsoat|glffct)-([0-9A-Za-z_-]{20,})(?![0-9A-Za-z_/-])"), 0.88), ("Anthropic API key", re.compile( r"\bsk-ant-([0-9A-Za-z_-]{20,})(?![0-9A-Za-z_/-])"), 1.0), ("OpenAI project key", re.compile( r"\bsk-(?:proj|svcacct)-([0-9A-Za-z_-]{20,})(?![0-9A-Za-z_/-])"), 1.0), ] # Opt-in entropy scan (--secret-entropy-scan). The generic assignment pattern # above uses a base64-standard value charset that excludes - and _, so a labeled # secret whose value is URL-safe (base64url: - _ =) slips past it. Widening that # charset would re-flag OKF key paths like `secret: svc/api/prod-key-path`, the # precision an earlier review round asked us to keep. This optional pass instead # matches only the base64url charset -- base64-standard values with `/` stay the # generic pattern's job -- and keeps precision two ways. Structurally (the primary # guard), the captured run must be a complete token: the trailing lookahead rejects # a run that is followed by another value char (we truncated a longer token) or a # `/` (it is a path segment, not a standalone value), so a documented key path like # `secret: prd-usw2-...-key-path/service` cannot leak its first segment as a value. # Excluding `/` from the class alone did not do this -- it stopped the match at the # separator but still captured a >=24-char leading segment. Statistically, a # Shannon-entropy floor backstops the slashless case: a random token scores above a # short dictionary-and-separator name (measured: 24-char base64url secrets land # ~4.05-4.4 bits/char, short human-readable names stay under 4.0). The floor is # imperfect -- a long, varied slashless name can clear it, the acknowledged # precision-for-recall tradeoff -- which is why the structural check, not this # threshold, is the primary guard. It is off by default so a normal run keeps the # narrow, zero-false-positive base64 behavior; the flag trades some precision for # recall. SECRET_ENTROPY_RE = re.compile( r"(?i)" + SECRET_LABEL + r"\s*[:=]\s*['\"]?([A-Za-z0-9_=-]{24,})" r"(?![A-Za-z0-9_=/-])['\"]?") SECRET_ENTROPY_MIN_BITS = 4.0 def shannon_entropy(s: str) -> float: """Shannon entropy of s in bits per character (0.0 for the empty string).""" if not s: return 0.0 n = len(s) return -sum((c / n) * math.log2(c / n) for c in Counter(s).values()) def prefixed_secret_labels(text: str): """Yield each provider label with a complete, random-looking token body.""" for label, pattern, max_entropy_fraction in PREFIXED_SECRET_PATTERNS: for match in pattern.finditer(text): body = match.group(1) # A sample of n characters cannot exhibit more than log2(n) bits of # entropy per character, even when every character is unique. min_entropy = min( SECRET_ENTROPY_MIN_BITS, max_entropy_fraction * math.log2(len(body)), ) if shannon_entropy(body) >= min_entropy: yield label break def entropy_secret_values(text: str): """Yield the labeled, high-entropy base64url values in text that the generic base64 pattern misses. A value must carry a base64url-only character (- _ =) -- otherwise the generic pattern already covers it -- and clear the entropy floor, so a low-entropy hyphenated name is left alone.""" for m in SECRET_ENTROPY_RE.finditer(text): value = m.group(1) if not any(ch in value for ch in "-_="): continue # plain base64/alnum -- already covered by SECRET_PATTERNS if shannon_entropy(value) >= SECRET_ENTROPY_MIN_BITS: yield value FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---\n?(.*)$", re.DOTALL) def parse_frontmatter(text: str): """Return (frontmatter_dict_or_None, body). Raises yaml.YAMLError on bad YAML.""" if not text.startswith("---"): return None, text m = FRONTMATTER_RE.match(text) if not m: return None, text return yaml.safe_load(m.group(1)) or {}, m.group(2) def frontmatter_block(text: str) -> str | None: """Return the raw YAML frontmatter text (between the --- fences), or None. Used to enforce rules on the source text before YAML strips comments (see check_source_quoting).""" if not text.startswith("---"): return None m = FRONTMATTER_RE.match(text) return m.group(1) if m else None def link_destination(raw: str) -> tuple[str, str]: """Pull the path and fragment out of a markdown link's (...) contents: strip <...> wrapping and an optional "title"/'title'. Markdown allows [text](dest "title") and [text](<dest with spaces>), treating the whole contents as the path would falsely flag those as dangling.""" s = raw.strip() if s.startswith("<"): end = s.find(">") if end != -1: destination = s[1:end].strip() path, separator, fragment = destination.partition("#") return path, separator + fragment s = s.split(None, 1)[0] if s else s # dest ends at first space; rest is a title path, separator, fragment = s.partition("#") return path, separator + fragment def resolve_link(target: str, md_file: Path) -> Path: """Resolve a relative link destination against the file's directory. .resolve() collapses ../ so the bundle-boundary check is not fooled by a path like ../../outside.md.""" return (md_file.parent / target).resolve() def is_rooted_link(target: str) -> bool: """True for POSIX roots, Windows roots, drive paths, and UNC paths.""" windows_path = PureWindowsPath(target) return target.startswith(("/", "\\")) or bool(windows_path.drive) def real_case_path( dest: Path, bundle: Path, *, allow_nonconforming_md: bool = False ) -> Path | None: """The path on disk that `dest` names, ignoring case, or None if nothing matches. Returns `dest` unchanged when every component already matches the real name. Path.exists() asks the filesystem, and macOS answers yes for `Concepts/Foo.md` when the file is really `concepts/foo.md`. A bundle written there passes validation on the author's machine and dangles the first time Linux CI or a Linux reader opens it, which is the class of break this validator exists to catch before it ships. So walk the components against the real directory listings instead of asking whether the path exists. The walk doubles as the existence check: a component that matches nothing, case or no case, means the link dangles. Walk the link's spelling before Path.resolve() can canonicalize its case on Windows. Keep each symlink and '..' component in order: collapsing '..' lexically can change the destination after a symlinked directory. `dest` is the uncollapsed link path starting at `bundle`. The caller keeps a separately resolved path for the bundle-boundary check. """ current = bundle for part in dest.relative_to(bundle).parts: if part == "..": current = current / part continue try: names = set(os.listdir(current)) except OSError: return None # not a directory, or unreadable: nothing below it resolves if part in names: current = current / part continue # Exactly one case-variant is a mismatch worth naming. Several means a # case-sensitive filesystem holding both, and no way to say which was meant. # Do not recommend a file with an uppercase .md extension as a link fix: that # filename is rejected elsewhere in this validator. The caller can opt into a # second lookup solely to give that non-conforming file a rename diagnostic. variants = [n for n in names if n.lower() == part.lower()] if not allow_nonconforming_md and Path(part).suffix.lower() == ".md": variants = [n for n in variants if Path(n).suffix == ".md"] if len(variants) != 1: return None current = current / variants[0] return current def strip_code(text: str) -> str: """Blank out fenced code blocks and inline code spans so a link shown as an example (e.g. a ```md fence containing [x](sample.md)) is not mistaken for a real bundle link. The secret scan still runs on the raw text, a secret in a code block is still a leak. Heuristic, not a full CommonMark parser: it handles ```/~~~ fences (matching the closing fence's char and length, so a longer fence can wrap a shorter one) and backtick-run inline spans (``code with a ` inside``). Rare forms, 4-space indented code blocks, code spans spanning lines, are out of scope; an OKF concept that needs those can wrap the example in a fence.""" out = [] fence = None # (char, length) of the open fence, or None for line in text.splitlines(): stripped = line.lstrip() if fence is None: m = re.match(r"(`{3,}|~{3,})", stripped) if m: fence = (stripped[0], len(m.group(1))) out.append("") else: out.append(re.sub(r"(`+)(.+?)\1", "", line)) # drop inline code spans else: ch, length = fence m = re.match(r"(`{3,}|~{3,})\s*$", stripped) if m and stripped[0] == ch and len(m.group(1)) >= length: fence = None out.append("") return "\n".join(out) def _plain_scalar_dropped_comment(node, raw_fm): """True if a YAML comment directly truncated this scalar's provenance. `node` is a scalar node from the parsed frontmatter and `raw_fm` the raw text it was parsed from. Only a plain (unquoted) scalar can lose data: a quoted or block scalar (node.style set to ', ", |, or >) keeps a '#' as string content. For a plain scalar, YAML stops the value at the space before an inline '#', so the comment shows up in the raw text immediately after the scalar's end mark, on the same line. That is exactly the silent-truncation case (`issue #445` -> "issue") the SPEC quoting rule guards against; `issue#445` (no space) is one scalar and a '#' on a later line is a standalone comment, and neither trips this.""" if node.style is not None: return False j = node.end_mark.index while j < len(raw_fm) and raw_fm[j] in (" ", "\t"): j += 1 return j < len(raw_fm) and raw_fm[j] == "#" # PyYAML resolves both `<<` and an explicit `!!merge` tag to this tag; a key carrying it is a # merge directive regardless of how it is spelled, so it is never a real mapping key. _MERGE_TAG = "tag:yaml.org,2002:merge" def _child_ref_counts(root): """Map id(node) -> how many times it is referenced as a child across the tree. A YAML alias makes the composer reuse the anchor's node object, so an alias TARGET is the one node referenced 2+ times. Each unique node is traversed once (guarded by a seen set) so a recursive anchor cannot loop; references are still counted with multiplicity.""" counts: dict[int, int] = {} seen: set[int] = set() stack = [root] while stack: node = stack.pop() if id(node) in seen: continue seen.add(id(node)) children: list = [] if isinstance(node, yaml.MappingNode): for k, v in node.value: children += (k, v) elif isinstance(node, yaml.SequenceNode): children += list(node.value) for c in children: counts[id(c)] = counts.get(id(c), 0) + 1 stack.append(c) return counts def _value_uses_alias(val_node, counts): """True if any node in this value's subtree is reached via a YAML alias. An alias target is the same node object referenced 2+ times in the whole frontmatter (counts), or reached twice while walking this one subtree (a cycle). An unused anchor is referenced once, so an anchored literal is not flagged, consistent with allowing `source: [&p "x"]`. Rejecting alias USES closes #169: an aliased scalar shares its anchor's position marks, so the end-mark quoting check cannot see a comment dropped after the alias.""" seen: set[int] = set() stack = [val_node] while stack: node = stack.pop() if id(node) in seen: return True # reached twice within source -> an alias points back into it seen.add(id(node)) if counts.get(id(node), 0) >= 2: return True # shared with another reference -> an alias target if isinstance(node, yaml.MappingNode): for k, v in node.value: stack += (k, v) elif isinstance(node, yaml.SequenceNode): stack += list(node.value) return False def _mapping_yields_source(node, seen): """True if this mapping node's effective keys include 'source', counting keys reached through its own (possibly nested) merge keys. A merged mapping can itself merge in another mapping ('<<: *d' where d is '<<: {source: ...}'), so checking only the immediate keys misses a source that safe_load still materializes. Recurse through each merge key's mapping(s). The seen set guards a recursive anchor (&d {<<: *d}) from looping.""" if not isinstance(node, yaml.MappingNode) or id(node) in seen: return False seen.add(id(node)) for key, val in node.value: if not isinstance(key, yaml.ScalarNode): continue if key.tag == _MERGE_TAG: for merged in (val.value if isinstance(val, yaml.SequenceNode) else [val]): if _mapping_yields_source(merged, seen): return True elif key.value == "source": return True return False def _merge_supplies_source(root): """True if a top-level YAML merge key (<<) merges in a mapping that yields its own 'source' key, directly or through a further nested merge. YAML lets a literal 'source:' override a merged one, so a source smuggled in through '<<: {source: ...}' (or a chain of merges that resolves to source) beside a literal is silently dropped and the top-level source-key scan (which keys on literal 'source' nodes) never sees it. A merge value is a mapping, or a sequence of mappings ('<<: [*a, *b]'); compose resolves an alias to the shared node, so an aliased merge map is a MappingNode here. Detecting it lets a merge-supplied source be rejected whether or not a literal source sits beside it.""" for key, val in root.value: if not (isinstance(key, yaml.ScalarNode) and key.tag == _MERGE_TAG): continue for node in (val.value if isinstance(val, yaml.SequenceNode) else [val]): if _mapping_yields_source(node, set()): return True return False def check_source_quoting(rel, fm, raw_fm, errors): """Enforce the SPEC 'source' quoting rule, where YAML's comment stripping would otherwise silently drop part of a provenance pointer. Quoting source elements is a hard SPEC rule, but YAML drops an unquoted inline '#' comment with no error: `- issue #445` parses to "issue", losing "#445". The parsed value alone cannot reveal the loss, so this re-parses the frontmatter into its node tree (which carries source position marks) and, for every top-level `source` element, checks whether a comment directly truncated a plain scalar (see _plain_scalar_dropped_comment). Delegating the lexing to YAML covers every shape, block items, single- and multi-line flow lists, wrapped scalars, quoted strings with escapes, anchors/tags, and block scalars, without re-implementing the parser. It is scoped to the top-level `source` key only (a nested `source:` under other metadata is not the OKF provenance list). A real parse error is reported by the schema check. `fm` is the safe_load result the caller already parsed. A `source` can enter it through a YAML merge key (`<<: {source: *r}`), a whole-node alias, an alias in key position, or a duplicate `source` key, each materializes the field, but YAML keeps the LAST of duplicate keys, so the value safe_load returns is not necessarily the first clean `source:` the scan finds. OKF source must be one literal top-level list, so this requires the effective source to come from exactly one literal, unshared `source:` key and rejects every indirection (merge, alias, duplicate) up front, which also keeps the node-tree quoting scan total.""" if not raw_fm: return try: root = yaml.compose(raw_fm, Loader=yaml.SafeLoader) except yaml.YAMLError: return if not isinstance(root, yaml.MappingNode): return counts = _child_ref_counts(root) # YAML keeps the LAST of duplicate keys, so the value safe_load returns for `source` is # decided by the last top-level key that resolves to "source", not the first clean one. # Collect every such key. An alias in key position (`*k` resolving to "source") makes # compose reuse the anchor's node, so the key reads as a "source" scalar while the written # key is an alias, count >= 2 marks that sharing, so an aliased key is not unshared. A key # SPELLED "source" but carrying the explicit merge tag (`!!merge source:`) is a merge # directive, not a source key, PyYAML classifies merge by the tag, not the spelling, so # exclude it here: counting it would both falsely reject a file whose only real source is a # separate literal, and let a source merged in through it bypass the scan below. source_keys = [ (k, v) for k, v in root.value if isinstance(k, yaml.ScalarNode) and k.value == "source" and k.tag != _MERGE_TAG ] unshared = [(k, v) for k, v in source_keys if counts.get(id(k), 0) < 2] if fm.get("source") is not None and ( len(source_keys) != 1 or not unshared or _merge_supplies_source(root) ): errors.append( f"{rel}: 'source' is not a single literal top-level 'source:' key, it enters " f"through a YAML merge key (<<), an alias, or a duplicate 'source' key; OKF " f"'source' must be one literal top-level list of provenance pointers, declare " f"it directly instead of merging, aliasing, or duplicating it") return for key_node, val_node in source_keys: # A YAML anchor/alias that entangles source with the rest of the frontmatter # shares node identity and position marks, so the dropped-comment scan below # reads the anchor's definition line, not the use site, and can miss a truncated # pointer (#169). OKF source is a flat list of literal, self-contained pointers # anyway, so any anchor/alias SHARING is invalid here, reject it, which keeps the # quoting scan total. An unused anchor definition (`[&p "x"]`) shares nothing and # stays allowed. if _value_uses_alias(val_node, counts): errors.append( f"{rel}: a top-level 'source' value shares a YAML anchor/alias (& or *) " f"with the rest of the frontmatter; OKF 'source' must be a flat list of " f"literal provenance pointers, write each pointer out literally instead " f"of anchoring or aliasing it") return if isinstance(val_node, yaml.SequenceNode): scalars = [n for n in val_node.value if isinstance(n, yaml.ScalarNode)] elif isinstance(val_node, yaml.ScalarNode): scalars = [val_node] else: scalars = [] if any(_plain_scalar_dropped_comment(n, raw_fm) for n in scalars): errors.append( f"{rel}: a top-level 'source' element has an unquoted '#' that YAML " f"reads as a comment, dropping the rest of the pointer, quote each " f"source element that contains a '#'") return # one report per concept is enough ISO_DATE_RE = re.compile(r"[0-9]{4}-[0-9]{2}-[0-9]{2}\Z") ISO_DATETIME_RE = re.compile( r"[0-9]{4}-[0-9]{2}-[0-9]{2}[T ][0-9]{2}:[0-9]{2}:[0-9]{2}" r"(?:[.,][0-9]+)?(?:Z|[+-][0-9]{2}:[0-9]{2})?\Z" ) def _is_iso_date(s): """True only for the literal YYYY-MM-DD form required by the SPEC.""" if not ISO_DATE_RE.fullmatch(s): return False try: dt.datetime.strptime(s, "%Y-%m-%d") except ValueError: return False return True def _is_iso_datetime(s): """True for the SPEC's full ISO 8601 datetime spelling. ``fromisoformat`` checks calendar and clock ranges, but it is deliberately not the lexical contract: both it and PyYAML accept wider timestamp forms. The regular expression first requires the exact zero-padded source shape. A trailing ``Z`` is normalised for Python versions that do not parse it. """ if not ISO_DATETIME_RE.fullmatch(s): return False try: dt.datetime.fromisoformat(s[:-1] + "+00:00" if s.endswith("Z") else s) except ValueError: return False return True def _literal_scalar_node(raw_fm, *path): """Return an unshared literal scalar node along a mapping/sequence path. String path components select the effective (last) literal mapping key; integer components index a sequence. Aliases are rejected because their source marks describe the anchor rather than the use site. """ if not raw_fm or not path: return None try: root = yaml.compose(raw_fm, Loader=yaml.SafeLoader) except yaml.YAMLError: return None if not isinstance(root, yaml.MappingNode): return None counts = _child_ref_counts(root) node = root for component in path: if isinstance(component, str): if not isinstance(node, yaml.MappingNode): return None candidates = [ (key_node, val_node) for key_node, val_node in node.value if isinstance(key_node, yaml.ScalarNode) and key_node.value == component and key_node.tag != _MERGE_TAG and counts.get(id(key_node), 0) < 2 ] if not candidates: return None _, node = candidates[-1] # safe_load keeps the last duplicate key elif isinstance(component, int): if (not isinstance(node, yaml.SequenceNode) or component < 0 or component >= len(node.value)): return None node = node.value[component] else: return None if counts.get(id(node), 0) >= 2: return None return node if isinstance(node, yaml.ScalarNode) else None def _raw_mapping_scalar(raw_fm, *path): """Return a literal scalar's unnormalised text along a node path, or None. ``safe_load`` constructs a broad family of YAML timestamp spellings as ``date``/``datetime`` objects. Calling ``isoformat`` on those objects would silently turn a malformed source spelling into a conforming value. Compose the same frontmatter into a node tree and inspect each effective (last) literal key along the path instead. Simple quoted strings remain supported; YAML aliases, tags, block scalars, and escape-based spellings are not literal date fields. """ node = _literal_scalar_node(raw_fm, *path) if node is None: return None written = raw_fm[node.start_mark.index:node.end_mark.index] if node.style is None and written == node.value: return written if node.style in ("'", '"') and written == node.style + node.value + node.style: return node.value return None def _mapping_scalar_dropped_comment(raw_fm, *path): """True when a literal scalar at path was truncated by a YAML comment.""" node = _literal_scalar_node(raw_fm, *path) return node is not None and _plain_scalar_dropped_comment(node, raw_fm) def _raw_top_level_scalar(raw_fm, key): """Return a literal top-level scalar's unnormalised text, or None.""" return _raw_mapping_scalar(raw_fm, key) def check_dates(rel, fm, raw_fm, bundle_version, errors): accepts_datetime = supports_datetime_timestamp(bundle_version) for key in date_keys_for(bundle_version): val = fm.get(key) if val is None: continue # missing/empty already reported by required-key check raw = _raw_top_level_scalar(raw_fm, key) if raw is not None and _is_iso_date(raw): continue # `timestamp` is upstream OKF's key and upstream writes it as a full ISO # 8601 datetime. Version 0.3 and later accept and carry that precision; # older formats remain date-only. `verified` is this spec's own key and # always stays date-only because a time of day invites false precision. if (key in DATETIME_KEYS and accepts_datetime and raw is not None and _is_iso_datetime(raw)): continue if key in DATETIME_KEYS and not accepts_datetime: expected = ( f"an ISO date YYYY-MM-DD under okf_version {bundle_version or '<missing>'}; " f"full ISO 8601 datetimes require okf_version {DATETIME_TIMESTAMP_VERSION}" ) elif key in DATETIME_KEYS: expected = "an ISO date YYYY-MM-DD or a full ISO 8601 datetime" else: expected = "an ISO date YYYY-MM-DD" shown = raw if raw is not None else val errors.append(f"{rel}: '{key}' must be {expected}, got {shown!r}") def check_lists(rel, fm, errors): for key in LIST_KEYS: val = fm.get(key) if val is None: continue # required-key check handles absence if not isinstance(val, list): errors.append(f"{rel}: '{key}' must be a YAML list, got {type(val).__name__}") continue if key == "source" and not val: errors.append(f"{rel}: 'source' must be a non-empty list of provenance pointers") for el in val: if not isinstance(el, str) or not el.strip(): errors.append(f"{rel}: '{key}' has a non-string/empty element {el!r}") def check_generated(rel, fm, raw_fm, errors): """Optional upstream-v0.2 'generated' field: {by, at} -- who/what produced the current content and when.""" if "generated" not in fm: return val = fm["generated"] if not isinstance(val, dict): errors.append(f"{rel}: 'generated' must be a mapping {{by, at}}, got {type(val).__name__}") return by = val.get("by") if not isinstance(by, str) or not by.strip(): errors.append(f"{rel}: 'generated.by' must be a non-empty string") at = val.get("at") if at is None: errors.append(f"{rel}: 'generated.at' is required when 'generated' is present") return raw = _raw_mapping_scalar(raw_fm, "generated", "at") if raw is None or not (_is_iso_date(raw) or _is_iso_datetime(raw)): shown = raw if raw is not None else at errors.append(f"{rel}: 'generated.at' must be an ISO date or a full ISO 8601 datetime, got {shown!r}") def check_verified_trust(rel, fm, raw_fm, bundle_version, errors): """Optional upstream-v0.2 'verified' field: a list of independent {by, at} confirmations, from which a consumer derives a trust tier (unverified / machine-confirmed / human-reviewed). Only meaningful at TRUST_SIGNALS_VERSION, where 'verified' is not the required key (see REQUIRED_KEYS_V04's 'verified_on') -- at every earlier version 'verified' IS the required legacy single-date field, already checked by check_dates, so this must not also re-validate it there under the new shape.""" if bundle_version != TRUST_SIGNALS_VERSION: return if "verified" not in fm: return # optional; absence reads as "unverified" to a consumer, not an error val = fm["verified"] if not isinstance(val, list) or not val: errors.append( f"{rel}: 'verified' (trust confirmations) must be a non-empty YAML list " f"of {{by, at}} mappings when present, got {type(val).__name__}") return for i, entry in enumerate(val): if not isinstance(entry, dict): errors.append(f"{rel}: 'verified[{i}]' must be a mapping with 'by' and 'at', got {type(entry).__name__}") continue by = entry.get("by") if not isinstance(by, str) or not by.strip(): errors.append(f"{rel}: 'verified[{i}].by' must be a non-empty string") at = entry.get("at") if at is None: errors.append(f"{rel}: 'verified[{i}].at' is required") else: raw = _raw_mapping_scalar(raw_fm, "verified", i, "at") if raw is None or not _is_iso_date(raw): shown = raw if raw is not None else at errors.append( f"{rel}: 'verified[{i}].at' must be an ISO date YYYY-MM-DD, " f"got {shown!r}") def check_sources_plural(rel, fm, raw_fm, errors): """Optional upstream-v0.2 'sources' field (plural) -- structured provenance objects, distinct from the required singular 'source' (a flat list of quoted pointers). Each entry needs a unique 'id' and a 'resource'; title/author/ usage_count/last_modified are optional credibility signals. Shape-checked only -- this does not cross-check in-body [^id] footnotes against these ids (see SPEC.md).""" if "sources" not in fm: return val = fm["sources"] if not isinstance(val, list) or not val: errors.append( f"{rel}: 'sources' must be a non-empty YAML list of provenance objects " f"when present, got {type(val).__name__}") return seen_ids = set() for i, entry in enumerate(val): if not isinstance(entry, dict): errors.append(f"{rel}: 'sources[{i}]' must be a mapping, got {type(entry).__name__}") continue sid = entry.get("id") if not isinstance(sid, str) or not sid.strip(): errors.append(f"{rel}: 'sources[{i}].id' must be a non-empty string") elif sid in seen_ids: errors.append( f"{rel}: 'sources[{i}].id' {sid!r} duplicates an earlier entry -- " f"ids must be unique within 'sources'") else: seen_ids.add(sid) resource = entry.get("resource") if not isinstance(resource, str) or not resource.strip(): errors.append(f"{rel}: 'sources[{i}].resource' must be a non-empty string") elif _mapping_scalar_dropped_comment(raw_fm, "sources", i, "resource"): errors.append( f"{rel}: 'sources[{i}].resource' has an unquoted '#' that YAML " f"reads as a comment, dropping the rest of the provenance pointer") for opt_key in ("title", "author"): v = entry.get(opt_key) if v is not None and (not isinstance(v, str) or not v.strip()): errors.append(f"{rel}: 'sources[{i}].{opt_key}' must be a non-empty string when present") uc = entry.get("usage_count") if uc is not None and (not isinstance(uc, int) or isinstance(uc, bool) or uc < 0): errors.append(f"{rel}: 'sources[{i}].usage_count' must be a non-negative integer when present, got {uc!r}") lm = entry.get("last_modified") if lm is not None: raw = _raw_mapping_scalar(raw_fm, "sources", i, "last_modified") if raw is None or not _is_iso_date(raw): shown = raw if raw is not None else lm errors.append( f"{rel}: 'sources[{i}].last_modified' must be an ISO date " f"YYYY-MM-DD when present, got {shown!r}") def check_status(rel, fm, errors): """Optional upstream-v0.2 'status' field: draft/stable/deprecated. Absent means stable (nothing to check).""" if "status" not in fm: return val = fm["status"] if not isinstance(val, str) or val not in ALLOWED_STATUSES: errors.append(f"{rel}: 'status' must be one of {sorted(ALLOWED_STATUSES)} when present, got {val!r}") def check_stale_after(rel, fm, raw_fm, errors): """Optional upstream-v0.2 'stale_after' field: an absolute ISO date, deliberately not a relative TTL (see SPEC.md).""" if "stale_after" not in fm: return val = fm["stale_after"] raw = _raw_mapping_scalar(raw_fm, "stale_after") if raw is None or not _is_iso_date(raw): shown = raw if raw is not None else val errors.append( f"{rel}: 'stale_after' must be an ISO date YYYY-MM-DD, got {shown!r}") def check_attested_computation(rel, fm, errors): """Upstream-v0.2 'Attested Computation' type: a sanctioned computation plus the means to check that a run of it actually matches. Checks shape only -- this validator never executes the computation, the executor, or the attester; that is a consumer's runtime job (see SPEC.md).""" if fm.get("type") != "Attested Computation": return runtime = fm.get("runtime") if not isinstance(runtime, str) or not runtime.strip(): errors.append(f"{rel}: 'Attested Computation' requires a non-empty 'runtime' string") params = fm.get("parameters") if not isinstance(params, list): errors.append(f"{rel}: 'Attested Computation' requires a 'parameters' list") else: for i, p in enumerate(params): if not isinstance(p, dict): errors.append(f"{rel}: 'parameters[{i}]' must be a mapping {{name, type, required}}, got {type(p).__name__}") continue extra = sorted(set(p) - {"name", "type", "required"}) if extra: errors.append(f"{rel}: 'parameters[{i}]' has undeclared keys {extra}") for key in ("name", "type"): v = p.get(key) if not isinstance(v, str) or not v.strip(): errors.append(f"{rel}: 'parameters[{i}].{key}' must be a non-empty string") if not isinstance(p.get("required"), bool): errors.append(f"{rel}: 'parameters[{i}].required' must be true or false") executor = fm.get("executor") if not isinstance(executor, dict): errors.append(f"{rel}: 'Attested Computation' requires an 'executor' mapping {{resource, receipt}}") else: resource = executor.get("resource") if not isinstance(resource, str) or not resource.strip(): errors.append(f"{rel}: 'executor.resource' must be a non-empty string") receipt = executor.get("receipt") if not isinstance(receipt, list) or not receipt or not all( isinstance(r, str) and r.strip() for r in receipt): errors.append(f"{rel}: 'executor.receipt' must be a non-empty list of non-empty strings") attester = fm.get("attester") if not isinstance(attester, dict): errors.append(f"{rel}: 'Attested Computation' requires an 'attester' mapping {{resource}}") else: resource = attester.get("resource") if not isinstance(resource, str) or not resource.strip(): errors.append(f"{rel}: 'attester.resource' must be a non-empty string") def declared_bundle_version(bundle): """Read the root marker for version-dependent field checks. This is a non-reporting pre-pass; the main file loop remains responsible for all root-index diagnostics. Reading it up front means a root-level concept named ``a.md`` receives the right grammar even though it sorts before ``index.md``. """ root_index = bundle / "index.md" if not root_index.is_file(): return None try: fm, _ = parse_frontmatter(root_index.read_text(encoding="utf-8-sig")) except (OSError, yaml.YAMLError, ValueError): return None if not isinstance(fm, dict) or fm.get("okf_version") is None: return None return str(fm["okf_version"]).strip() def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--bundle", default="bundle", help="path to the OKF bundle directory (default: bundle)") ap.add_argument( "--secret-entropy-scan", action="store_true", help="also flag a labeled URL-safe/base64url value whose Shannon entropy " "clears the secret floor (opt-in; trades some precision for recall on " "hyphenated secret values the base64 pattern misses)") args = ap.parse_args() bundle = Path(args.bundle).resolve() if not bundle.exists(): print(f"FAIL: bundle not found at {bundle}") return 1 if not bundle.is_dir(): print(f"FAIL: bundle path is not a directory: {bundle}") return 1 # Discover markdown files case-insensitively (suffix .lower() == ".md") so a # non-conforming Foo.MD cannot hide from validation behind a case-sensitive glob; # it is found here and rejected below. rglob("*") also yields a directory named # like "archive.md"; reading one raises IsADirectoryError, so keep only real files # and report any .md-suffixed path that is a directory rather than crashing. md_entries = sorted(p for p in bundle.rglob("*") if p.suffix.lower() == ".md") md_files = [p for p in md_entries if p.is_file()] errors: list[str] = [ f"{p.relative_to(bundle)}: a '*.md' path must be a file, not a directory" for p in md_entries if not p.is_file() ] # A bundle must have a root index.md. The okf_version gate below only runs when # that file exists, so without this check a bundle that simply omits the root # index (or an empty directory) would validate clean and bypass version gating. if not (bundle / "index.md").is_file(): errors.append("index.md: bundle-root index is required and must declare okf_version") bundle_version = declared_bundle_version(bundle) type_counts: Counter = Counter() concepts = 0 for f in md_files: rel = f.relative_to(bundle) # utf-8-sig strips a leading byte-order mark if present. A BOM (common from # Windows editors) would otherwise defeat the startswith("---") frontmatter # check, reporting valid frontmatter as missing. text = f.read_text(encoding="utf-8-sig") # secret scan on every file, including index.md and a non-conforming Foo.MD # (a leak is a leak regardless of extension, scan before rejecting below). for label, pat in SECRET_PATTERNS: if pat.search(text): errors.append( f"{rel}: possible secret leak ({label}), remove the value, " f"document the key name/path instead") for label in prefixed_secret_labels(text): errors.append( f"{rel}: possible secret leak ({label}), remove the value, " f"document the key name/path instead") if args.secret_entropy_scan and next(entropy_secret_values(text), None): errors.append( f"{rel}: possible secret leak (high-entropy assignment flagged by " f"--secret-entropy-scan), remove the value, document the key " f"name/path instead") # OKF concept and index files use a lowercase .md extension. A non-lowercase # extension (Foo.MD) is non-conforming: it was discovered case-insensitively # above so its content is still secret-scanned, then rejected here instead of # validated as a concept. With the case-insensitive link check below, this # closes the bypass where an uppercase-extension file and links to it both # escaped validation. if f.suffix != ".md": errors.append( f"{rel}: non-conforming filename, OKF concept files use a lowercase " f"'.md' extension, found {f.suffix!r}; rename it to .md") continue try: fm, body = parse_frontmatter(text) except (yaml.YAMLError, ValueError) as e: # ValueError covers a date-shaped scalar PyYAML auto-constructs and # rejects (e.g. an invalid month), which is not a YAMLError subclass. errors.append( f"{rel}: YAML frontmatter parse error ({e.__class__.__name__}: {e}), " f"quote any string field that holds a YAML-significant character. " f"Common triggers: a colon-space (': ') anywhere in description, title, " f"or a source element; a bare '#' in a source element; an invalid date " f"in verified/timestamp.") continue # Past this point fm is either None or a mapping. Syntactically valid YAML # that is a list/scalar (e.g. a stray top-level list) would otherwise crash # the later fm.get(...) calls; report it as a clean failure instead. if fm is not None and not isinstance(fm, dict): errors.append(f"{rel}: frontmatter must be a YAML mapping, got {type(fm).__name__}") continue if f.name in RESERVED: if str(rel) == "index.md": # the bundle-root index.md may carry frontmatter, but only the # okf_version marker, never concept metadata or stray keys. keys = set(fm) if fm else set() if "okf_version" not in keys: errors.append("index.md: bundle-root index must declare okf_version in frontmatter") else: version = str(fm.get("okf_version")).strip() if version not in SUPPORTED_VERSIONS: errors.append( f"index.md: okf_version {fm.get('okf_version')!r} is not supported " f"(this validator supports {', '.join(SUPPORTED_VERSIONS)})") extra = sorted(keys - {"okf_version"}) if extra: errors.append(f"index.md: bundle-root index may carry only okf_version, found {extra}") elif fm is not None: # any other reserved file (subdir index.md, log.md) carries no frontmatter. errors.append(f"{rel}: reserved file should not carry frontmatter") continue if fm is None: errors.append(f"{rel}: missing YAML frontmatter") continue concepts += 1 ctype = fm.get("type", "<none>") # type must be a scalar string. A list/dict (a plausible YAML typo like # `type: [Reference]`) is unhashable and would crash both the Counter # increment and the closed-vocabulary membership test, so report it and # fall back to "<none>" to keep the rest of this concept's checks running. if not isinstance(ctype, str): errors.append(f"{rel}: 'type' must be a string, got {type(ctype).__name__}") ctype = "<none>" type_counts[ctype] += 1 for key in required_keys_for(bundle_version): val = fm.get(key) if val is None or (isinstance(val, str) and not val.strip()): errors.append(f"{rel}: missing/empty required frontmatter key '{key}'") allowed_types = allowed_types_for(bundle_version) if ctype not in allowed_types and ctype != "<none>": errors.append(f"{rel}: type '{ctype}' not in the spec vocab {sorted(allowed_types)}") check_lists(rel, fm, errors) raw_fm = frontmatter_block(text) check_dates(rel, fm, raw_fm, bundle_version, errors) check_source_quoting(rel, fm, raw_fm, errors) if bundle_version == TRUST_SIGNALS_VERSION: check_generated(rel, fm, raw_fm, errors) check_verified_trust(rel, fm, raw_fm, bundle_version, errors) check_sources_plural(rel, fm, raw_fm, errors) check_status(rel, fm, errors) check_stale_after(rel, fm, raw_fm, errors) check_attested_computation(rel, fm, errors) # Link resolution: every internal link to a .md file must resolve to a file # that exists inside the bundle. A link escaping the bundle root or pointing at # a missing file is a hard failure, the bundle is validated as one # self-contained tree. To validate federated content, assemble the bundles into # a single tree and point --bundle at that root. for f in md_files: if f.suffix != ".md": continue # non-conforming file already reported; don't pile on link errors text = strip_code(f.read_text(encoding="utf-8-sig")) for m in WIKILINK_RE.finditer(text): slug = m.group(1).strip() errors.append( f"{f.relative_to(bundle)}: '[[{slug}]]' is not an OKF link, use a " f"relative markdown link like [text]({slug}.md). The [[slug]] form is " f"the auto-memory convention, not OKF.") for raw in LINK_RE.findall(text): target, fragment = link_destination(raw) if not target: continue low = target.lower() # External/anchor links are out of scope. Lower-case the scheme test so an # uppercase scheme (HTTPS://...) is still recognized and skipped, not # resolved as a local path (which would falsely fail as escaping/dangling). if low.startswith(("http://", "https://", "mailto:", "#", "tel:")): continue # Match .md case-insensitively, the same way discovery now does, so a link # to an uppercase-extension file (ghost.MD) is checked for dangling/escape # instead of silently skipped. If such a target file exists it is separately # rejected as non-conforming above, so the two checks stay in agreement. if ".md" not in low: continue if is_rooted_link(target): errors.append(f"{f.relative_to(bundle)}: root-relative link not allowed " f"(use a relative path) -> {target}") continue spelled = f.parent / target real = real_case_path(spelled, bundle) if real is not None: try: resolved_real = real.resolve() except (OSError, RuntimeError): real = None else: if not resolved_real.is_relative_to(bundle): errors.append(f"{f.relative_to(bundle)}: link escapes bundle root -> {target}") continue try: dest = resolve_link(target, f) except (OSError, RuntimeError): errors.append(f"{f.relative_to(bundle)}: dangling link -> {target}") continue inside = dest == bundle or bundle in dest.parents if not inside and real is None: errors.append(f"{f.relative_to(bundle)}: link escapes bundle root -> {target}") else: if real is not None and not real.exists(): real = None if real is None: nonconforming = real_case_path( spelled, bundle, allow_nonconforming_md=True ) if nonconforming is not None: try: resolved_nonconforming = nonconforming.resolve() except (OSError, RuntimeError): nonconforming = None else: if not resolved_nonconforming.is_relative_to(
-
-
README.md 1.8 KB
# claude-skills-journalism wiki An Open Knowledge Format (OKF) knowledge base: small markdown files, one concept each, with provenance in YAML frontmatter. See `SPEC.md` for the full contract. ## Requirements The validator parses YAML frontmatter with PyYAML: ```bash pip install -r requirements.txt # or: pip install pyyaml ``` ## Validate ```bash python3 scripts/validate.py --bundle bundle ``` It must exit 0. Run it before every commit. ## Add a concept 1. Create `bundle/<section>/<concept>.md` with the required frontmatter (`type, title, description, source, verified, timestamp, tags`). 2. Add a bullet for it in that section's `index.md`. 3. Validate. ## Session hooks `.claude/` ships two hooks that orient Claude on this knowledge base before it works: - `okf-anchor.py` (SessionStart) loads the bundle index into the session context. - `okf-orient.py` (PreToolUse) blocks the first action once per session until Claude confirms it has read the index, then unblocks for the rest of the session. They are one cross-platform python3 script each. No single interpreter name works on every OS, so `settings.json` names the one for the OS this bundle was scaffolded on (`python3` on macOS/Linux, `python` on Windows). If you move the bundle to a different OS, change that one token in `settings.json` (or re-run the scaffolder there). Claude Code treats a checked-in `.claude/settings.json` as untrusted, so the first time you open this project it asks you to approve the hooks. They run automatically after that. Delete `.claude/` (or set `disableAllHooks`) to turn them off. ## Security Never put secret values in a concept. A credential concept documents the key name and where it is retrieved, not the value. The validator scans for leaked secrets and fails on a hit. -
requirements.txt 87 B
# The validator (scripts/validate.py) parses YAML frontmatter with PyYAML. PyYAML>=5.1 -
SPEC.md 20.8 KB
# OKF spec v1 Open Knowledge Format (OKF) is a convention for storing knowledge as small markdown files that both people and agents can read. One file describes one concept and carries its own provenance. Directory `index.md` files provide navigation. A validator enforces the contract so the knowledge base stays consistent as it grows. This is the generic spec. A project may layer its own conventions on top (extra tags, naming patterns, a fixed section list), but must not weaken the rules below. ## A note on version numbers Three separate numbers show up in this project, and none of them track each other: 1. **Upstream Google OKF's own spec version**, `0.1` (June 2026), then `0.2` (July 2026, adding the trust/provenance/attestation vocabulary this document adopts below). This is Google's number, not this fork's. 2. **This skill's package version** (the `version:` field in `SKILL.md`'s frontmatter), its own release-numbering axis for the skill/plugin itself (bug fixes, secret-scanner hardening, Codex compatibility, and so on). It has no relationship to either spec version. 3. **This fork's own bundle-format version** (the `okf_version` marker every bundle-root `index.md` declares, and what `SUPPORTED_VERSIONS` in `validate.py` checks), `0.1`, then `0.2` (new allowed types), then `0.3` (datetime-form `timestamp`), and now `0.4` (this patch: the v0.2 trust/provenance fields below, and the required-key rename they force). So "OKF v0.2" (upstream Google's spec) and "`okf_version: 0.2`" (a bundle declared under *this fork's* second format revision, from months before Google's v0.2 existed) are two unrelated things that happen to share a digit. Where this document says "upstream v0.2" it means Google's; a bare "`okf_version: 0.4`" always means this fork's own marker. ## Relationship to upstream OKF This spec is a strict fork of Google's Open Knowledge Format ([GoogleCloudPlatform/knowledge-catalog](https://github.com/GoogleCloudPlatform/knowledge-catalog/tree/main/okf)). It keeps the core idea (knowledge as small markdown files with YAML frontmatter and `index.md` navigation) and tightens the contract so a validator can enforce it. If you already know upstream OKF, these are the intentional differences to author against; upstream conventions will otherwise produce files this validator rejects. - Required keys. Upstream requires only `type` and treats everything else as recommended, with any extra key allowed. Here, for a bundle declaring `okf_version` `0.1` through `0.3`, all seven of `type`, `title`, `description`, `source`, `verified`, `timestamp`, and `tags` are required and non-empty. For a bundle declaring `0.4`, `verified` is renamed to `verified_on` in that required list (see "Trust and provenance (upstream v0.2 vocabulary)" for why), so a `0.4` bundle requires `type`, `title`, `description`, `source`, `verified_on`, `timestamp`, and `tags` instead. Either way, a file that conforms upstream (say, `type` alone) fails validation here. - `resource` becomes `source`. Upstream's optional `resource` is one canonical URI for the underlying asset. This spec drops `resource` and requires `source`, a non-empty list of provenance pointers (paths, commands, URLs, events). Upstream v0.2 separately introduces its own richer `sources` (plural), this fork adopts that too, as an optional companion to the required `source`; see below. - Citations fold into `source`. Upstream lists sources under a `# Citations` heading and allows a `references/` subdirectory. Here there is neither; all provenance lives in the `source` frontmatter list (and, optionally, the richer `sources` list below). - `verified`/`verified_on` is added. Upstream v0.1 has no such key. Through `okf_version` `0.3`, this spec requires `verified`, the ISO date the fact was last confirmed true, kept distinct from `timestamp` (when the file was authored or edited). At `0.4`, this fork's own field is renamed `verified_on` to free the name `verified` for upstream v0.2's own `verified`, a different thing (see below). - `timestamp`'s datetime form. Upstream's `timestamp` is an ISO 8601 datetime, and `check_dates` accepts that form for `timestamp` in a `0.3`-or-later bundle (with or without an offset, including a trailing `Z`), so an upstream bundle validates here unchanged. A `0.1`/`0.2` bundle's `timestamp` stays date-only. `verified`/`verified_on` is always date-only, in every version: it records the day a fact was last confirmed true, and a time of day there is false precision about the confirmation. - The type vocab is closed. Upstream types are freeform and unregistered, and consumers must tolerate unknown ones. Here the set is fixed (see Type vocab) and an unlisted type fails the build, which catches typos; extending it is a deliberate spec edit. - Links are strict, and must be relative. Upstream treats a broken link as tolerable ("consumers MUST tolerate broken links") and permits bundle-root-relative targets like `/tables/customers.md`. Here every intra-bundle link must resolve or validation fails, the `[[slug]]` wikilink form is rejected outright, and any target beginning with `/` is rejected as a root-relative link, so an upstream `/tables/customers.md` must be rewritten relative to the file that links it. - A root `index.md` declaring `okf_version` is mandatory. Upstream treats `index.md` and `okf_version` as optional. Here the bundle root must contain an `index.md` whose frontmatter declares `okf_version` (and carries nothing else), so an upstream bundle with no root index, or one that omits the version marker, fails validation. - Secret values fail the build. Upstream OKF has no secret-scanning rule. Here `validate.py` scans every markdown file for private-key blocks, cloud-token shapes, and `secret=<value>` assignments and fails the bundle on a hit, so an upstream bundle that inlines a credential value passes upstream but is rejected here (see Security). - Trust/provenance/attestation fields are adopted, not required. Upstream v0.2 adds `generated`, `sources`, `status`, `stale_after`, `verified` (the new, upstream shape), and an `Attested Computation` type, all of it optional there, and all of it optional here too. Adopting none of it leaves a bundle exactly as valid as before; see the dedicated section below. This fork's own format version differs from upstream's on a separate axis (see "A note on version numbers" above): upstream is now `0.1`/`0.2`, this fork's current bundle-format version is `0.4` (the validator still accepts `0.1`, `0.2`, and `0.3`). Every difference above pulls the same direction: upstream stays minimal and needs no tooling to stay portable, while this fork adds a validator that fails the build when a bundle drifts. ## Bundle model A bundle is a directory tree. The simplest bundle is one directory of concept files with an `index.md`. Larger bundles group concepts into subdirectories by subject. ``` <bundle-root>/ index.md carries okf_version, here and nowhere else <section>/ index.md navigation for the section <concept>.md one concept per file ``` ## Files - The bundle-root `index.md` carries `okf_version` (one of `"0.1"`–`"0.4"`; `scaffold.py` still writes `"0.3"` by default, `"0.4"` only with `--trust-signals`) in frontmatter, only there. - Per-directory `index.md`: a heading, an optional one-line preamble, then bullet navigation. No frontmatter. (Keep the preamble to one line, it orients, it doesn't narrate.) - `log.md` (optional, per directory): dated entries, newest first, no frontmatter. - Concept files: one concept each, frontmatter required (below). - Reserved filenames: `index.md`, `log.md`. - All markdown files use a lowercase `.md` extension. A non-lowercase extension (`.MD`, `.Md`) is non-conforming and rejected, the validator discovers files case-insensitively so such a file cannot escape validation, and link checks match `.md` case-insensitively too. The validator enforces the frontmatter rules here, reserved files carry no frontmatter, and only the bundle-root `index.md` carries `okf_version` (and nothing else). The index/log body shapes above are recommendations for human readers, not validator-checked structure. The current format version is `0.4`, but `scaffold.py` still emits `0.3` by default, unchanged from before this patch, and only writes `0.4` when explicitly asked (`scaffold.py --trust-signals`; see Tooling). The validator accepts `0.1` through `0.4` either way, so a newer validator still reads an older bundle. Version `0.2` added allowed types. Version `0.3` admitted a full datetime in `timestamp`. Version `0.4` renames `verified` to `verified_on` (freeing `verified` for the new upstream-v0.2 shape) and adds the optional trust/provenance fields below, a bundle that adopts none of them still only needs to declare `0.4` because of the rename; one that keeps declaring `0.1`–`0.3` keeps the old `verified` field exactly as before and simply cannot use the new optional fields (see next section). The marker makes these grammar changes explicit, so an older validator reports a clear unsupported-version error instead of a misleading field error. ## Concept frontmatter (required keys, all non-empty) For a bundle declaring `okf_version` `0.1` through `0.3`: | key | value | | --- | --- | | `type` | one of the type vocab below | | `title` | the concept name | | `description` | one line | | `source` | YAML list of provenance pointers (paths, commands, URLs, events) | | `verified` | ISO date `YYYY-MM-DD` the fact was last confirmed true (see note below) | | `timestamp` | ISO date authored/updated, or in a `0.3` bundle a full ISO 8601 datetime | | `tags` | YAML list | For a bundle declaring `okf_version` `0.4`, the table is identical except `verified` is named `verified_on` (same required-ness, same meaning, same date-only rule, only the key name changes, to make room for the new `verified` described below). `verified`/`verified_on` note: it records when the fact was last confirmed true, which is not always today. A fact you re-checked against reality now is confirmed today, as is one the user is the authority for, a decision, preference, or intent they state directly. But a fact the user is recalling about external or system state is a source claim, not a re-check: date it to when that state was last checked or to the recollection's own date, not today. A claim copied from a dated source without re-checking carries that source's date. A fact taken from an undated record you cannot re-confirm (a memory file, an old conversation) carries the oldest date you can evidence, file timestamp, introducing commit, or the date it was said, never today; if no date can be evidenced, the fact is not yet verifiable, so find a datable source or leave it out. When the date is uncertain, round it down: an older `verified`/`verified_on` reads as "may be stale," today reads as "just confirmed." The date is the contract; a caveat in the concept body does not undo it, because the validator and tools read only the date. `timestamp` may be an ISO date (`YYYY-MM-DD`) in every supported format version. A `0.3` or `0.4` bundle may instead preserve a full datetime in the exact form `YYYY-MM-DDTHH:MM:SS[.fraction][Z|+HH:MM|-HH:MM]`; a space may replace `T`. Versions `0.1` and `0.2` remain date-only. `verified`/`verified_on` is always date-only. `source` quoting rule (hard): QUOTE every element of the `source` list. Source pointers routinely carry YAML-significant characters, a `#` (e.g. `"issue #445"`) starts a comment and corrupts the flow sequence, a colon-space `: ` splits a mapping, so a strict parser rejects an unquoted source. Always quote them: ```yaml source: ["README.md", "issue #445", "git log 9c2e510"] ``` The validator enforces this in both list styles. In flow style (`["a", b]`) an unquoted element carrying a significant character fails to parse and is reported as an error. In block style (`- a`) YAML would silently drop an inline `#` comment and pass, so the validator also scans the raw source text and rejects an unquoted element with a `#`. An element that is already quote-safe (a bare filename) is accepted either way, quote everything anyway so you never have to judge which is which. `tags` and `description` follow a lighter rule: quote an element only when it contains a YAML-significant character (a colon-space `: `, a leading `[ { # * & ! | > % @` or quote, or a trailing `:`); plain kebab tokens like `canonical` may stay unquoted. Quoting when unsure is always safe. Hard quoting is `source` only. Provenance lives in `source`, there is no separate citations section or references directory. ## Trust and provenance (upstream v0.2 vocabulary) Upstream Google OKF v0.2 (July 2026) added a second kind of frontmatter field: not one that describes a concept, but one a consumer uses to decide whether to trust it before reading the body. This fork adopts that vocabulary as optional additions, available on any concept regardless of type, in a bundle declaring `okf_version` `0.4`. None of it is required. A concept that uses none of these fields is exactly as valid as one authored against `0.1`. As with upstream, this fork records the raw signals and leaves scoring to the consumer, there is no computed trust score anywhere in a concept file or in the validator's output. A tool that wants "only surface human-reviewed metrics" derives that filter itself from the fields below. - **`generated`**, an optional mapping `{by, at}`: who or what produced the current content, and when it last meaningfully changed. `by` is a non-empty string identifying the producer (a model/agent name, or `human:<id>`); `at` is an ISO date or full ISO 8601 datetime. This sits alongside the required `timestamp`, not in place of it, `timestamp` is this fork's own authored/updated marker and stays required; `generated` is the richer, optional upstream form for describing production, and the two may describe the same event. - **`verified`** (only meaningful in a `0.4` bundle, where it is not the required key, see above), an optional YAML list of independent confirmations, each a mapping `{by, at}`: `by` a non-empty string (a `human:<id>` actor or a machine/agent identifier), `at` an ISO date. A consumer derives a trust tier from this list: no `verified` key is *unverified*; every entry from a machine/agent actor only is *machine-confirmed*; any entry from a `human:<id>` actor is *human-reviewed*. The validator checks only that the list is well-formed (non-empty entries, valid dates), deriving and filtering on a tier is a consumer's job, not this format's. - **`sources`** (plural, distinct from the required singular `source`), an optional YAML list of structured provenance objects, each with a required `id` (non-empty string, unique within the list, used to key an in-body footnote like `[^warehouse-schema]`) and `resource` (non-empty string: a URL or bundle-relative path), plus optional `title`, `author` (non-empty strings), `usage_count` (a non-negative integer), and `last_modified` (an ISO date). Where the required `source` is a flat list of quoted pointers, `sources` lets each pointer carry its own credibility signals and be cited per-claim in the body via an ordinary markdown footnote. The validator checks shape only, it does not cross-check that every `[^id]` footnote in the body has a matching `sources[].id`, or vice versa; treat that cross-reference as a human/reviewer responsibility for now. - **`status`**, an optional string, one of `draft`, `stable`, `deprecated`. Absent means `stable`. A `deprecated` concept is kept for history/reproducibility but should not be surfaced to new work. - **`stale_after`**, an optional ISO date `YYYY-MM-DD`. An absolute date, deliberately, not a relative TTL: staleness is then a plain date comparison with no reference to when the concept happened to be read. None of the above changes what the validator requires; it only makes the *absence* of these fields distinguishable from their presence where they matter to a consumer deciding whether to act on a concept. Absence means the signal was not supplied. A trust field that is explicitly present with a YAML null value is invalid; present fields must have the shape documented above. ## Type vocab Infrastructure and ops (fleet maps, system docs): `Machine`, `Network`, `Service`, `Session`, `Project`, `Repo`, `Credential`, `Path`, `Process`. Domain-neutral (newsrooms, research atlases, decision logs): `Concept`, `Decision`, `Event`, `Person`, `Org`, `Source`. `Reference` is the catch-all for a concept that is not one of the others. Index files carry no frontmatter, so there is no `Index` type. The set is closed: an unlisted type fails the build, which catches typos. To extend it, add the type here and in `scripts/validate.py`. `Attested Computation` joins this closed vocabulary only in a bundle declaring `okf_version` `0.4`; versions `0.1` through `0.3` reject it. ### `Attested Computation` (upstream v0.2) A concept of this type carries a sanctioned way to compute a value, and the means to check that the sanctioned computation actually ran, the answer to "was this number produced the way we said it must be," distinct from `verified` above (which confirms a *definition* still matches policy, not that any one run produced a correct value). It carries these keys in addition to the seven (or, at `0.4`, six-plus-`verified_on`) base required keys: | key | value | | --- | --- | | `runtime` | non-empty string naming the execution environment (e.g. `bigquery`) | | `parameters` | YAML list (possibly empty) of mappings, each `{name, type, required}`, the declared inputs a caller may fill; nothing else | | `executor` | mapping `{resource, receipt}`, `resource` points at the skill/tool that runs the computation, `receipt` a list naming what it returns (e.g. `[job_id, executed_sql, result]`) | | `attester` | mapping `{resource}`, points at the deterministic, non-LLM checker that compares a receipt against this concept's sanctioned computation | OKF records the computation and how to check it; this fork's validator checks only that these four keys are present and correctly shaped when `type: Attested Computation`. It never runs the computation, the executor, or the attester itself, that is a consumer's runtime responsibility, entirely outside this format and this validator. ## Links Relative markdown links. Every link to a file inside the bundle must resolve to a file that exists; a link that escapes the bundle root or dangles fails validation. The bundle is validated as one self-contained tree (see Federation for combining several). A link's case must match the file on disk. Case-insensitive filesystems on macOS and Windows can resolve `Concepts/Foo.md` to `concepts/foo.md`, while the same link dangles on Linux. The validator checks the spelling of each link component against directory listings before resolving can replace it with canonical casing. Its error gives the corrected relative link. Symlink and `..` components stay in filesystem traversal order, and the resolved destination must remain inside the bundle root. The `[[slug]]` wikilink form is not an OKF link, and the validator rejects it. It is the auto-memory cross-reference idiom and easy to reach for by habit, but a `[[slug]]` is never resolved or checked, so a dead reference would pass silently. Always link with `[text](relative/path.md)`. ## Federation (optional) Several bundles can be combined into one tree. Add a new root `index.md` that carries `okf_version` and links to each member, then place each bundle under a uniquely named subdirectory of that root. A member's own `index.md` is now a nested section index, so remove its `okf_version` frontmatter block entirely, a nested `index.md` carries no frontmatter at all. Write cross-bundle links as relative paths into the sibling directories. Validate by pointing the validator at the new root, so every link resolves and the single `okf_version` gate runs once at the top. A member's marker is stripped when it is nested, so it can no longer be validated on its own from inside the combined tree, validate the assembled root instead. (Per-node validation that keeps a marker in each member is planned but not yet built.) Most single-repo knowledge bases never need any of this. ## Security (hard) - No secret VALUES anywhere. A credential concept documents the key name, where it lives, and how it is retrieved, never the value itself. - The validator scans for private-key blocks, cloud-token shapes, and `secret=<value>` assignments and fails the build on a hit. If a pattern false-positives on legitimate text, narrow the pattern; do not delete the rule. - OKF makes no claim about whether your bundle is public or private. That is your decision, but a bundle that documents real infrastructure is usually internal. Decide deliberately before publishing. ## Tooling - `validate.py`, frontmatter conformance, date/list checks, link resolution, secret scan. Run it before every commit; it must exit 0. - `scaffold.py`, generate a conforming starter bundle.
-
-
scripts
-
gh-wiki-bootstrap.py 7.5 KB
#!/usr/bin/env python3 """gh-wiki-bootstrap.py, create the FIRST page of an empty GitHub wiki. Why this exists: a repo with the wiki feature enabled but zero pages has no `<repo>.wiki.git` repo yet. `git clone`/`git push` both fail with "Repository not found," and GitHub exposes no REST API for wiki content. The only way to create the first page is the web UI. After that one page exists, the wiki is a normal git repo: clone it, add pages, push. So this script does the minimum web-UI step, create one page (default "Home") via Playwright using the saved GitHub web session, and prints the wiki URL. Everything after (real content, multiple pages) should go through git. Auth: reuses the saved Playwright storageState at ~/.cache/gh_state.json, the same session kept alive by gh-session-keepwarm.py. The github PAT does NOT work for this, wiki pages are a web-UI-only surface. Usage: gh-wiki-bootstrap.py owner/repo gh-wiki-bootstrap.py owner/repo --title Home --body "Initializing wiki." gh-wiki-bootstrap.py owner/repo --headed # watch it run, for debugging Exit codes: 0 ok, 2 not authenticated, 3 body editor not found, 4 save button not found, 5 bad arguments, 6 save not confirmed. Then: git clone https://github.com/owner/repo.wiki.git """ import argparse import os import re import sys from urllib.parse import urlsplit DEFAULT_STATE = os.path.expanduser("~/.cache/gh_state.json") def _still_on_editor(url): """True while the browser is still sitting on the new-page editor. The new-wiki-page editor lives at the path `/<owner>/<repo>/wiki/_new` and, on a successful save, GitHub redirects to `/<owner>/<repo>/wiki/<slug>`. The save-success signal is leaving that editor path. Testing the whole URL for the substring "_new" misfires whenever "_new" appears anywhere else in it, the owner, the repo name (e.g. owner/service_new), or a saved page's slug: the redirected URL still contains the substring, so a real save reads as a failure and the tool wrongly reports it never saved. Match the editor by its path suffix instead, ignoring a trailing slash and any query or fragment. """ return urlsplit(url).path.rstrip("/").endswith("/wiki/_new") def parse_args(): ap = argparse.ArgumentParser(description="Create the first page of an empty GitHub wiki.") ap.add_argument("repo", help="target repo as owner/name, e.g. jamditis/ccm") ap.add_argument("--title", default="Home", help="first page title (default: Home)") ap.add_argument("--body", default="Initializing wiki.", help="first page body text") ap.add_argument("--state", default=DEFAULT_STATE, help="Playwright storageState json (default: ~/.cache/gh_state.json)") ap.add_argument("--headed", action="store_true", help="run with a visible browser (debugging)") ap.add_argument("--screenshot-dir", default=None, help="if set, save before/after screenshots here") args = ap.parse_args() if not re.fullmatch(r"[^/\s]+/[^/\s]+", args.repo): ap.error("repo must look like owner/name") if not os.path.exists(args.state): ap.error(f"session state not found: {args.state} (run gh-session-keepwarm.py or re-auth)") return args def shot(page, screenshot_dir, name): if screenshot_dir: os.makedirs(screenshot_dir, exist_ok=True) page.screenshot(path=os.path.join(screenshot_dir, name)) def main(): args = parse_args() # Imported here so --help works without playwright installed. from playwright.sync_api import sync_playwright, TimeoutError as PWTimeout new_url = f"https://github.com/{args.repo}/wiki/_new" wiki_url = f"https://github.com/{args.repo}/wiki" with sync_playwright() as p: browser = p.chromium.launch(headless=not args.headed) ctx = browser.new_context(storage_state=args.state) page = ctx.new_page() page.goto(new_url, wait_until="domcontentloaded", timeout=45000) url = page.url print("landed on:", url) if "/login" in url: print("error: not authenticated (redirected to login); refresh the session", file=sys.stderr) browser.close() sys.exit(2) # Wait for the editor to actually render rather than sleeping a fixed # interval, so a slow page load is not mistaken for a missing control. title_sel = "input#wiki_page_title, input[name='wiki[name]'], input[name='wiki[title]']" try: page.wait_for_selector(title_sel, state="visible", timeout=20000) except PWTimeout: pass # editor never appeared; the body-editor check below reports it shot(page, args.screenshot_dir, "wiki_01_new.png") # Title field (often prefilled with "Home" on the first page). for sel in ["input#wiki_page_title", "input[name='wiki[name]']", "input[name='wiki[title]']"]: if page.locator(sel).count(): page.fill(sel, args.title) print("title set via", sel) break # Body: plain textarea (classic gollum editor) first, then CodeMirror. body_filled = False if page.locator("textarea#gollum-editor-body").count(): page.fill("textarea#gollum-editor-body", args.body) body_filled = True print("body filled via textarea#gollum-editor-body") else: for sel in [".CodeMirror textarea", ".cm-content", "div[contenteditable='true']", "textarea[name='wiki[content]']"]: if page.locator(sel).count(): page.click(sel) page.keyboard.type(args.body) body_filled = True print("body typed via", sel) break shot(page, args.screenshot_dir, "wiki_02_filled.png") if not body_filled: print("error: could not locate the wiki body editor", file=sys.stderr) browser.close() sys.exit(3) # Save. clicked = False for sel in ["button:has-text('Save Page')", "button[name='commit']", "input[type='submit'][value*='Save']", "button:has-text('Save')"]: if page.locator(sel).count(): page.locator(sel).first.click() clicked = True print("clicked save via", sel) break if not clicked: print("error: could not find the Save button", file=sys.stderr) browser.close() sys.exit(4) # On success GitHub redirects away from the editor (.../_new) to the # created page. Wait for that navigation rather than a fixed sleep, so a # slow-but-successful save is not mistaken for a failure. saved = True try: page.wait_for_url(lambda u: not _still_on_editor(u), timeout=20000) except PWTimeout: saved = False final_url = page.url shot(page, args.screenshot_dir, "wiki_03_saved.png") print("final url:", final_url) browser.close() # If we never left the editor, the save was rejected (no wiki write access, # bad title, inline validation error), do not report success. if not saved or _still_on_editor(final_url): print(f"error: wiki editor never redirected to a created page ({final_url}), " "save not confirmed. Check the saved session has wiki write access and " "the title is valid.", file=sys.stderr) sys.exit(6) print("wiki bootstrapped. next:") print(f" git clone https://github.com/{args.repo}.wiki.git") print(f" open: {wiki_url}") if __name__ == "__main__": main() -
scaffold.py 31.5 KB
#!/usr/bin/env python3 """scaffold.py: generate a conforming OKF (Open Knowledge Format) starter bundle. Creates a project that passes its own validator by construction: <target>/ SPEC.md the format contract (copied from the skill) README.md how to use and validate this bundle scripts/validate.py the validator (copied from the skill) .claude/ session hooks that orient Claude on the bundle settings.json registers the hooks (Claude Code asks you to approve once) hooks/okf-anchor.py SessionStart: load the index into context hooks/okf-orient.py PreToolUse: gate the first action on orientation bundle/ the OKF bundle (this is what gets validated) index.md carries okf_version <section>/ index.md section navigation example-concept.md a starter concept with full frontmatter SPEC.md and README.md live at the project root, NOT inside bundle/, because the validator treats every non-reserved .md inside the bundle as a concept that needs frontmatter. Keeping docs out of bundle/ means a fresh scaffold validates clean. The .claude/ hooks are one cross-platform python3 script each; only the launch command in settings.json differs per OS (python3 on macOS/Linux, python on Windows). Claude Code treats a checked-in .claude/settings.json as untrusted, so the user approves the hooks once on first session open. See SKILL.md for the hook contract. Usage: scaffold.py ./my-knowledge-base scaffold.py ./kb --title "Team knowledge base" --sections concepts,services,decisions scaffold.py ./kb --no-validate # skip the post-scaffold validation run scaffold.py ./kb --no-hooks # do not write the .claude/ session hooks scaffold.py ./kb --hooks-os windows # force the Windows launch command (default: this OS) """ from __future__ import annotations import argparse import datetime as dt import importlib.util import json import platform import re import shlex import shutil import stat import subprocess import sys from pathlib import Path SKILL_ROOT = Path(__file__).resolve().parent.parent SRC_SPEC = SKILL_ROOT / "spec" / "SPEC.md" SRC_VALIDATOR = SKILL_ROOT / "scripts" / "validate.py" SRC_HOOKS = SKILL_ROOT / "templates" / "hooks" HOOK_SCRIPTS = ("okf-anchor.py", "okf-orient.py") # The exact hook-command paths this scaffold writes (see cmd() in claude_settings). # We identify our own hook entries by exact match on these strings, so a re-run # replaces only what we generated and never a user's hook that merely lives at a # similarly named path elsewhere (e.g. /opt/shared/.claude/hooks/okf-anchor.py). OKF_HOOK_PATHS = frozenset(f"${{CLAUDE_PROJECT_DIR}}/.claude/hooks/{s}" for s in HOOK_SCRIPTS) # validate.py imports PyYAML to parse frontmatter; a scaffolded project declares it # so the dependency is explicit, not discovered via a traceback. Mirrors the skill's # own requirements.txt. REQUIREMENTS_TXT = ( "# The validator (scripts/validate.py) parses YAML frontmatter with PyYAML.\n" "PyYAML>=5.1\n" ) def slugify(name: str) -> str: return "-".join("".join(c if c.isalnum() else " " for c in name.lower()).split()) def write_if_absent(path: Path, content: str, preserved: list[Path]) -> None: """Write content unless the file already exists. On --force into a populated directory an existing file is the user's own, so preserve it instead of clobbering it with a generic template. On a fresh scaffold nothing exists, so every file is written as before.""" if path.exists(): preserved.append(path) return path.write_text(content, encoding="utf-8") def copy_if_absent(src: Path, dst: Path, preserved: list[Path]) -> None: """copy2 src to dst unless dst exists (see write_if_absent for the rationale).""" if dst.exists(): preserved.append(dst) return shutil.copy2(src, dst) def root_index(title: str, sections: list[str], version: str = "0.3") -> str: nav = "\n".join(f"- [{s}]({s}/index.md)" for s in sections) return ( f'---\nokf_version: "{version}"\n---\n' f"# {title}\n\n" "An Open Knowledge Format bundle. One concept per file; provenance in each file's frontmatter.\n\n" "## Sections\n\n" f"{nav}\n" ) def section_index(section: str) -> str: return ( f"# {section}\n\n" f"Concepts in the {section} section.\n\n" "- [example concept](example-concept.md)\n" ) def example_concept(today: str, trust_signals: bool = False) -> str: # Pre-0.4, this fork's own field is named 'verified'. At 0.4 it is renamed # 'verified_on' to free 'verified' for upstream v0.2's own (differently # shaped) field of the same name -- see SPEC.md's "Trust and provenance" # section and validate.py's REQUIRED_KEYS_V04. verified_key = "verified_on" if trust_signals else "verified" return ( "---\n" "type: Reference\n" "title: Example concept\n" "description: A starter concept showing the OKF frontmatter contract.\n" 'source: ["SPEC.md", "scaffold.py"]\n' f"{verified_key}: {today}\n" f"timestamp: {today}\n" "tags: [example, starter]\n" "---\n" "# Example concept\n\n" "Replace this file with a real concept. Keep it to one concept per file.\n\n" "- Point every `source` element at real provenance (a path, command, URL, or event).\n" f"- Update `{verified_key}` when you re-check the fact against reality.\n" "- Link related concepts with relative markdown links, like this one to [the section index](index.md).\n" ) def readme(title: str, hooks: bool, interp: str = "python3", trust_signals: bool = False) -> str: verified_key = "verified_on" if trust_signals else "verified" hooks_section = ( "## Session hooks\n\n" "`.claude/` ships two hooks that orient Claude on this knowledge base before it works:\n\n" "- `okf-anchor.py` (SessionStart) loads the bundle index into the session context.\n" "- `okf-orient.py` (PreToolUse) blocks the first action once per session until Claude\n" " confirms it has read the index, then unblocks for the rest of the session.\n\n" "They are one cross-platform python3 script each. No single interpreter name works on\n" "every OS, so `settings.json` names the one for the OS this bundle was scaffolded on\n" "(`python3` on macOS/Linux, `python` on Windows). If you move the bundle to a different\n" "OS, change that one token in `settings.json` (or re-run the scaffolder there). Claude\n" "Code treats a checked-in `.claude/settings.json` as untrusted, so the first time you\n" "open this project it asks you to approve the hooks. They run automatically after that.\n" "Delete `.claude/` (or set `disableAllHooks`) to turn them off.\n\n" ) if hooks else "" return ( f"# {title}\n\n" "An Open Knowledge Format (OKF) knowledge base: small markdown files, one concept each,\n" "with provenance in YAML frontmatter. See `SPEC.md` for the full contract.\n\n" "## Requirements\n\n" "The validator parses YAML frontmatter with PyYAML:\n\n" "```bash\n" "pip install -r requirements.txt # or: pip install pyyaml\n" "```\n\n" "## Validate\n\n" "```bash\n" f"{interp} scripts/validate.py --bundle bundle\n" "```\n\n" "It must exit 0. Run it before every commit.\n\n" "## Add a concept\n\n" "1. Create `bundle/<section>/<concept>.md` with the required frontmatter\n" f" (`type, title, description, source, {verified_key}, timestamp, tags`).\n" "2. Add a bullet for it in that section's `index.md`.\n" "3. Validate.\n\n" f"{hooks_section}" "## Security\n\n" "Never put secret values in a concept. A credential concept documents the key name and\n" "where it is retrieved, not the value. The validator scans for leaked secrets and fails on a hit.\n" ) def resolve_hooks_os(choice: str) -> str: """Map --hooks-os (auto|posix|windows) to the concrete launch target.""" if choice == "auto": return "windows" if platform.system() == "Windows" else "posix" return choice def interpreter_for(hooks_os: str) -> str: """The python command for an OS. No single name works everywhere: macOS/Linux have python3, stock Windows has python (python3 is usually absent there).""" return "python" if hooks_os == "windows" else "python3" def claude_settings(hooks_os: str) -> dict: """Build the .claude/settings.json that registers the orientation hooks. The hook scripts are cross-platform python3; only the interpreter changes per OS (python3 on macOS/Linux, python on Windows). Each hook uses exec form -- the interpreter as `command` and the script path as a single `args` element -- which Claude Code spawns directly with no shell, so a project path with spaces or other special characters needs no quoting (the hooks docs recommend exec form for any hook that references a path placeholder). The path uses the ${CLAUDE_PROJECT_DIR} placeholder so it resolves even when the hook cwd has drifted from the project root, and stays correct when the bundle is cloned to another path -- unlike a baked-in absolute path. PreToolUse omits a matcher so it sees the first tool call of any kind. """ interp = interpreter_for(hooks_os) def cmd(script: str) -> dict: path = f"${{CLAUDE_PROJECT_DIR}}/.claude/hooks/{script}" # exec form: the script path is one args element, spawned with no shell, so a # project path with spaces or special characters needs no quoting. return {"type": "command", "command": interp, "args": [path]} return { "hooks": { "SessionStart": [{"hooks": [cmd("okf-anchor.py")]}], "PreToolUse": [{"hooks": [cmd("okf-orient.py")]}], } } def _is_okf_hook(h: dict) -> bool: """True if a hook entry launches one of the OKF orientation scripts we generate. We match the exact project-local path we write (`${CLAUDE_PROJECT_DIR}/.claude/ hooks/<script>`), found either as an exec-form `args` element or, after shlex- splitting, as a token of a shell-form `command` string. Exact matching against OKF_HOOK_PATHS -- not a suffix or substring test -- means a re-run replaces only our own entries and never a user's hook that merely ends in the same filename (`okf-anchor.py.bak`) or sits at a different absolute path. The check is total: any hook shape returns a bool and never raises, so a malformed entry (e.g. a non-list `args`) is simply judged "not ours" and left in place rather than crashing --force. """ args = h.get("args") tokens = list(args) if isinstance(args, (list, tuple)) else [] cmd = h.get("command") if isinstance(cmd, str): try: tokens.extend(shlex.split(cmd)) except ValueError: pass # unbalanced quotes: nothing we generate parses to this return any(str(t) in OKF_HOOK_PATHS for t in tokens) def merge_hook_settings(existing: dict, new: dict) -> dict: """Merge our hook groups into existing settings without losing anything recoverable. Every top-level key (e.g. permissions) and every unrelated hook event is preserved. Within an event we register, only our own hook entries are stripped: a group that also holds the user's hooks keeps them, and a group is dropped only when it empties. Then our fresh group is appended, so a re-run is idempotent and never deletes a user's hook that shared a group with ours. The function is total -- a non-dict `hooks`, a non-list event value, or a malformed group/entry holds no mergeable hook config and is treated as empty, so no input shape raises. write_claude_hooks backs up the original first when it had to reset such a malformed subtree. """ merged = dict(existing) eh = merged.get("hooks") hooks = dict(eh) if isinstance(eh, dict) else {} for event, groups in new["hooks"].items(): prior = hooks.get(event) prior = prior if isinstance(prior, list) else [] kept = [] for g in prior: inner = g.get("hooks") if isinstance(g, dict) else None if not isinstance(inner, list): kept.append(g) # a shape we don't manage -- leave it untouched continue remaining = [h for h in inner if not (isinstance(h, dict) and _is_okf_hook(h))] if len(remaining) == len(inner): kept.append(g) # no OKF hooks here elif remaining: kept.append({**g, "hooks": remaining}) # keep the user's hooks # else: the group held only our hooks -- drop it hooks[event] = kept + list(groups) merged["hooks"] = hooks return merged def write_claude_hooks(target: Path, hooks_os: str) -> None: """Copy the hook scripts into target/.claude/hooks and write settings.json. If settings.json already exists (e.g. --force into a project that already uses Claude Code), merge the OKF hooks into it rather than overwriting: the user's other settings and hook events survive, and re-running replaces only our own entries. A file that is not a JSON object at all is backed up to settings.json.bak and replaced. A JSON object whose hook subtree is malformed is repaired in place -- unrelated keys stay in the live file and the original is copied to settings.json.bak first, so the malformed copy is still recoverable. """ hooks_dir = target / ".claude" / "hooks" hooks_dir.mkdir(parents=True, exist_ok=True) for script in HOOK_SCRIPTS: dest = hooks_dir / script shutil.copy2(SRC_HOOKS / script, dest) # make executable for the shebang case; exec form also names the interpreter # explicitly, so this is belt-and-suspenders. dest.chmod(dest.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) settings_path = target / ".claude" / "settings.json" new_settings = claude_settings(hooks_os) if settings_path.exists(): try: existing = json.loads(settings_path.read_text(encoding="utf-8")) except (json.JSONDecodeError, OSError, UnicodeDecodeError): existing = None if isinstance(existing, dict): # The merge preserves every top-level key and unrelated event. If the hook # subtree itself is malformed (hooks not an object, or an event we write into # not a list), the merge resets just that subtree -- so copy the original to # .bak first to keep the malformed version recoverable, then merge in place so # unrelated settings (e.g. permissions) stay in the live file. existing_hooks = existing.get("hooks") clean = (existing_hooks is None or isinstance(existing_hooks, dict)) and ( existing_hooks is None or all( isinstance(existing_hooks.get(ev), (list, type(None))) for ev in new_settings["hooks"] ) ) if not clean: backup = settings_path.with_name(settings_path.name + ".bak") shutil.copy2(settings_path, backup) print(f"warning: {settings_path.name} had malformed hook settings; " f"repaired in place, original backed up to {backup.name}", file=sys.stderr) new_settings = merge_hook_settings(existing, new_settings) else: backup = settings_path.with_name(settings_path.name + ".bak") settings_path.replace(backup) print(f"warning: existing {settings_path.name} could not be merged " f"(not a JSON object); backed up to {backup.name}", file=sys.stderr) settings_path.write_text(json.dumps(new_settings, indent=2) + "\n", encoding="utf-8") def _canonical_req_name(line: str) -> str | None: """The PEP 503-normalized distribution name a requirements.txt line pins, or None when the line names no installable package (blank, comment, option, URL, or path). Used only to spot whether PyYAML is declared, so it is a name sniffer, not a full requirements parser.""" s = line.split("#", 1)[0].strip() # drop full-line and inline comments if not s or s.startswith("-") or s.startswith("."): return None # option/include (-r, -e, ...) or a local path, not a bare name # The project name is the leading token, ending at the first version specifier, # marker, extras bracket, whitespace, or "@" direct-reference separator. name = re.split(r"[<>=!~;,@\[ (]", s, maxsplit=1)[0].strip() if not name or "://" in name or "/" in name: return None # a bare URL or path names no distribution we can normalize return re.sub(r"[-_.]+", "-", name).lower() def _strip_pip_options(text: str) -> str: """Drop pip's per-requirement options (`--hash`, `--global-option`, ...) from a requirements line so packaging.requirements.Requirement, which accepts only PEP 508 and rejects these options, can parse the requirement. Mirrors pip's own break_args_options: split on spaces and keep tokens up to the first that starts with '-'. Rejoining the original space-split tokens preserves the requirement's quotes (unlike shlex, which would strip a marker string's quotes and make a valid marker unparseable); a hash digest and a marker value never begin a bare '-' token, so the requirement is kept whole while the trailing options are dropped.""" kept = [] for token in text.split(" "): if token.startswith("-"): break kept.append(token) return " ".join(kept) def _requirement_marker_selects_env(line: str) -> bool: """True if a requirements line has no PEP 508 environment marker, or one that selects the current interpreter, so pip would install it here. A marker that excludes this environment returns False: pip installs nothing from that line, so a PyYAML declaration gated to another platform (`PyYAML; sys_platform == "win32"` read on Linux) does not give validate.py the yaml module it imports on every platform. The full PEP 508 grammar (name, extras, direct-reference URL, marker) is parsed by `packaging`, so a '#' inside a quoted marker or a ';' inside a URL is not mistaken for a comment or the marker separator; pip's per-requirement options (`--hash` from a --generate-hashes lock file, etc.) are stripped first, since packaging rejects them. A trailing pip inline comment (which PEP 508 does not define) is only removed if the whole line fails to parse, so a '#' that is really inside a quoted marker value survives. If packaging is absent or the line does not parse as a requirement, returns True, so an unjudgeable line never becomes a false 'missing PyYAML' note (the behavior from before markers were read).""" # Drop pip per-requirement options (--hash, ...) first so packaging can parse the marker: # each is a space-separated token starting with '-', never part of a name or marker value. text = _strip_pip_options(line.strip()) if not text: return True try: from packaging.requirements import Requirement, InvalidRequirement except ImportError: return True # cannot parse without packaging: assume it installs, do not nag # A '#' inside a quoted marker value is valid PEP 508, so parse the whole line first; only # if that fails treat a trailing whitespace-'#' as a pip inline comment, strip it, and retry # -- removing a real comment without corrupting a marker value that legitimately holds '#'. try: req = Requirement(text) except InvalidRequirement: stripped = re.sub(r"(^|\s)#.*$", "", text).strip() if not stripped or stripped == text: return True # unparseable even without a trailing comment: do not nag try: req = Requirement(stripped) except InvalidRequirement: return True # not a parseable requirement line: do not nag if req.marker is None: return True # no marker: the line always applies try: return bool(req.marker.evaluate()) except Exception: # Any evaluation failure means the marker cannot be judged in this context: an undefined # variable or comparison, or a lock-file-only variable (dependency_groups) that raises a # raw KeyError in the default metadata context. Fall back to treating the line as # installable instead of crashing the --force path. The function is fail-open, so an # unjudgeable line never becomes a false missing-PyYAML note. return True def _join_continuations(text: str): """Yield logical requirement lines, joining pip's backslash line-continuations the way pip's requirements parser does: a physical line ending in '\\' continues onto the next, so a hash-locked entry (its marker on the first physical line, indented `--hash` options on the following lines) is reassembled into one logical line before its name, directives, and marker are read. Without it, the first physical line keeps a trailing '\\' that breaks marker parsing, and a marker split across the continuation would be missed entirely. A full-line comment never continues, even when it ends in '\\': pip's parser treats it as a standalone comment, so it is emitted on its own rather than swallowing the next line (which would hide a PyYAML declaration or an -r/-e include directive).""" buf = [] for raw in text.splitlines(): if raw.lstrip().startswith("#"): if buf: # flush a pending continuation before the standalone comment yield "".join(buf) buf = [] yield raw continue if raw.endswith("\\"): buf.append(raw[:-1]) continue buf.append(raw) yield "".join(buf) buf = [] if buf: # a final physical line ending in '\' with nothing after it yield "".join(buf) def _preserved_requirements_lacks_pyyaml(path: Path) -> bool: """True only when we can say with confidence that a preserved requirements.txt does not declare an installable PyYAML: it is readable, no line names PyYAML that would install here, and it carries no include or editable directive (-r, -e) that could pull PyYAML from a file we do not read. An unreadable file or such a directive returns False, so the targeted warning fires only when the gap is certain and never nags a user who did declare it. A constraint file (-c/--constraint) only pins versions of packages installed elsewhere and cannot supply a missing one, so it does not suppress the note. A PyYAML line gated by a PEP 508 environment marker counts only when the marker selects this interpreter: one gated to another platform installs nothing here, so it does not (see _requirement_marker_selects_env). Physical lines are first joined on pip's backslash continuations, so a hash-locked entry split across lines is read as one requirement.""" try: text = path.read_text(encoding="utf-8") except (OSError, UnicodeDecodeError): return False for raw in _join_continuations(text): s = raw.strip() if s.startswith(("-r", "--requirement", "-e", "--editable")): return False # PyYAML may live in the included (-r) or editable (-e) target if _canonical_req_name(raw) == "pyyaml" and _requirement_marker_selects_env(raw): return False # PyYAML declared and its marker (if any) installs here return True def main() -> int: ap = argparse.ArgumentParser(description="Generate a conforming OKF starter bundle.") ap.add_argument("target", help="directory to create the project in") ap.add_argument("--title", default=None, help="bundle title (default: derived from target dir name)") ap.add_argument("--sections", default="concepts", help="comma-separated section names (default: concepts)") ap.add_argument("--date", default=None, help="ISO date for sample frontmatter (default: today)") ap.add_argument("--force", action="store_true", help="write into a non-empty target directory") ap.add_argument("--no-validate", action="store_true", help="skip the post-scaffold validation run") ap.add_argument("--no-hooks", action="store_true", help="do not write the .claude/ session hooks") ap.add_argument("--hooks-os", choices=("auto", "posix", "windows"), default="auto", help="launch command for the hooks (default: auto-detect this OS)") ap.add_argument( "--trust-signals", action="store_true", help="scaffold under okf_version 0.4: renames 'verified' to 'verified_on' and " "enables the optional upstream-v0.2 trust/provenance fields (generated, " "the new 'verified', sources, status, stale_after, Attested Computation). " "Default is okf_version 0.3, unchanged from before this flag existed.") args = ap.parse_args() if not SRC_SPEC.exists() or not SRC_VALIDATOR.exists(): print(f"FAIL: skill assets missing ({SRC_SPEC} / {SRC_VALIDATOR})", file=sys.stderr) return 1 if not args.no_hooks and not all((SRC_HOOKS / s).exists() for s in HOOK_SCRIPTS): print(f"FAIL: hook templates missing in {SRC_HOOKS} (or pass --no-hooks)", file=sys.stderr) return 1 target = Path(args.target).resolve() if target.exists() and any(target.iterdir()) and not args.force: print(f"FAIL: {target} exists and is not empty (use --force to write anyway)", file=sys.stderr) return 1 title = args.title or Path(args.target).name.replace("-", " ").replace("_", " ").strip() or "knowledge base" # A section name that is all punctuation slugifies to "" and would write its # index to bundle/index.md, clobbering the root index. Reject those, and # dedupe so a repeated name does not overwrite a directory mid-loop. sections: list[str] = [] for raw in args.sections.split(","): if not raw.strip(): continue slug = slugify(raw) if not slug: print(f"FAIL: section name {raw!r} has no alphanumeric characters", file=sys.stderr) return 1 if slug not in sections: sections.append(slug) if not sections: print("FAIL: at least one section is required", file=sys.stderr) return 1 today = args.date or dt.date.today().isoformat() try: dt.datetime.strptime(today, "%Y-%m-%d") except ValueError: print(f"FAIL: --date must be ISO YYYY-MM-DD, got {today!r}", file=sys.stderr) return 1 bundle = target / "bundle" (target / "scripts").mkdir(parents=True, exist_ok=True) bundle.mkdir(parents=True, exist_ok=True) write_hooks = not args.no_hooks hooks_os = resolve_hooks_os(args.hooks_os) version = "0.4" if args.trust_signals else "0.3" # Skip-if-exists so --force into a populated directory never overwrites a user's # own SPEC.md/README.md/validator/bundle content with a generic template. (A # fresh scaffold has none of these, so all are written.) The .claude/ hooks are # stateless machinery, refreshed in place; settings.json is merged with backup. preserved: list[Path] = [] copy_if_absent(SRC_SPEC, target / "SPEC.md", preserved) copy_if_absent(SRC_VALIDATOR, target / "scripts" / "validate.py", preserved) write_if_absent(target / "requirements.txt", REQUIREMENTS_TXT, preserved) write_if_absent(target / "README.md", readme(title, write_hooks, interpreter_for(hooks_os), args.trust_signals), preserved) write_if_absent(bundle / "index.md", root_index(title, sections, version), preserved) for s in sections: sdir = bundle / s sdir.mkdir(parents=True, exist_ok=True) write_if_absent(sdir / "index.md", section_index(s), preserved) write_if_absent(sdir / "example-concept.md", example_concept(today, args.trust_signals), preserved) if write_hooks: write_claude_hooks(target, hooks_os) print(f"Scaffolded OKF project at {target}") print(f" title: {title}") print(f" sections: {', '.join(sections)}") if write_hooks: print(f" hooks: .claude/ written for {hooks_os} (Claude Code asks you to approve them on first open)") else: print(" hooks: skipped (--no-hooks)") if preserved: rels = ", ".join(str(p.relative_to(target)) for p in sorted(preserved)) print(f" preserved {len(preserved)} existing file(s), not overwritten: {rels}") print(" (delete a file and re-run to regenerate it from the template)") if args.no_validate: return 0 # The "validates by construction" guarantee only holds when the scaffold wrote # everything fresh. Once --force preserves any existing file, the result is not # guaranteed valid, and scripts/validate.py and bundle/index.md may themselves be # the user's own preserved files -- so running validation here could execute an # unrelated preserved validator or blame the scaffold for preserved user content. # Skip it and tell the user to validate when they have reconciled their files. if preserved: print("\nScaffold written, but skipping validation: existing files were " "preserved, so the bundle is not guaranteed valid by construction and " "scripts/validate.py may be your own.", file=sys.stderr) # A preserved requirements.txt is the user's own (#142), so we never edit it. But # if it omits PyYAML, the validate step below fails with ModuleNotFoundError, so # name the exact dependency here rather than leaving them to decode the traceback. req = target / "requirements.txt" if req in preserved and _preserved_requirements_lacks_pyyaml(req): print(" note: the preserved requirements.txt does not list PyYAML, which " "scripts/validate.py imports. Add PyYAML>=5.1 to it (or run " "`pip install pyyaml`) before validating. Without it, validation " "fails with ModuleNotFoundError.", file=sys.stderr) print(" reconcile the preserved files, then validate when ready:", file=sys.stderr) print(f" cd {target} && {interpreter_for(hooks_os)} scripts/validate.py --bundle bundle", file=sys.stderr) return 0 # validate.py runs under this same interpreter, so its `import yaml` succeeds # only if PyYAML is findable here. Check first: an absent dependency is a setup # gap, not a scaffold bug, and the scaffold itself succeeded. Report it plainly # and exit 0 rather than letting the subprocess traceback be mislabeled below. if importlib.util.find_spec("yaml") is None: print("\nScaffold written, but skipping validation: PyYAML is not installed " "for this interpreter.", file=sys.stderr) print(" install it, then validate:", file=sys.stderr) print(" pip install -r requirements.txt # or: pip install pyyaml", file=sys.stderr) print(f" cd {target} && {interpreter_for(hooks_os)} scripts/validate.py --bundle bundle", file=sys.stderr) return 0 print("\nValidating the new bundle...") res = subprocess.run( [sys.executable, str(target / "scripts" / "validate.py"), "--bundle", str(bundle)], capture_output=True, text=True) sys.stdout.write(res.stdout) if res.stderr: sys.stderr.write(res.stderr) if res.returncode != 0: print("FAIL: scaffold did not validate (this is a bug in scaffold.py)", file=sys.stderr) return res.returncode return 0 if __name__ == "__main__": sys.exit(main()) -
validate.py 61 KB
#!/usr/bin/env python3 """Validate an OKF (Open Knowledge Format) bundle against OKF spec v1 (see SPEC.md). An OKF bundle is a tree of small markdown files: one concept per file, each with YAML frontmatter carrying its provenance. Directory `index.md` files provide navigation. This validator enforces the contract so a bundle stays machine- and agent-readable. Checks: 1. Every non-reserved .md file has a parseable YAML frontmatter block. A YAML parse error is reported (commonly an unquoted colon-space or '#' in a string field, quote the value). 2. Frontmatter carries every required key, non-empty. Through okf_version 0.3: type, title, description, source, verified, timestamp, tags. At 0.4: the same list with 'verified' renamed to 'verified_on' (see #6). - type is one of the spec type vocab. - source is a non-empty list of non-empty strings (provenance pointers). An unquoted '#' in a block-style element (which YAML would silently drop as a comment, losing the rest) is rejected, quote it. - tags is a list. - verified/verified_on parses as an ISO date (YYYY-MM-DD). - timestamp parses as an ISO date, or under okf_version 0.3+ as a full ISO 8601 datetime (upstream OKF writes a datetime). 3. Reserved filenames (index.md, log.md) name no concept and carry no frontmatter, except the bundle-root index.md may carry okf_version only. 4. Internal markdown links resolve. Links must be relative, a root-relative ('/'-prefixed) link is rejected. Every link to a .md file inside the bundle must point at a file that exists, with the case it has on disk (a case-insensitive filesystem would otherwise let a wrong-case link pass on macOS or Windows and dangle on Linux); a link that escapes the bundle root or dangles is a hard failure. Optional link titles and <>-wrapped destinations are handled. The bundle is validated as one self-contained tree (to validate federated content, assemble the bundles into one tree and point --bundle at that root). 5. No file leaks a secret VALUE (private-key blocks, cloud API tokens, secret=<blob> assignments). Credential concepts document key NAMES and paths, never the values. Heuristic; narrow a pattern if it false-positives, do not delete the rule. 6. At okf_version 0.4, optional upstream-v0.2 trust/provenance fields are checked for shape when present (never required): 'generated' ({by, at}); the new 'verified' (a list of {by, at} confirmations, distinct from the required 'verified_on' at that version); 'sources' (plural; structured provenance objects, distinct from the required singular 'source'); 'status' (draft/stable/deprecated); 'stale_after' (an ISO date). A concept typed 'Attested Computation' additionally requires 'runtime', 'parameters', 'executor', and 'attester' to be present and correctly shaped. Exits non-zero on any hard failure. Usage: python3 validate.py --bundle DIR """ from __future__ import annotations import argparse import datetime as dt import math import os import re import sys from collections import Counter from pathlib import Path, PureWindowsPath import yaml REQUIRED_KEYS_LEGACY = ("type", "title", "description", "source", "verified", "timestamp", "tags") # At TRUST_SIGNALS_VERSION, 'verified' is renamed to 'verified_on' in the required # set -- it frees the bare name 'verified' for upstream v0.2's own optional field (a # list of {by, at} confirmations; see check_verified_trust), which is a different # shape and would otherwise collide with this fork's older single-date field. REQUIRED_KEYS_V04 = ("type", "title", "description", "source", "verified_on", "timestamp", "tags") LIST_KEYS = ("source", "tags") # Keys that may also carry a full ISO 8601 datetime (see check_dates). The # bundle must explicitly opt into this grammar through okf_version 0.3 so an # older validator rejects the format at its version gate instead of later on a # timestamp it does not understand. DATETIME_KEYS = ("timestamp",) DATETIME_TIMESTAMP_VERSION = "0.3" # The version at which 'verified' is renamed to 'verified_on' and the optional # upstream-v0.2 trust/provenance fields (generated, verified, sources, status, # stale_after, Attested Computation) become available. See REQUIRED_KEYS_V04 above # and SPEC.md's "Trust and provenance (upstream v0.2 vocabulary)" section. TRUST_SIGNALS_VERSION = "0.4" LEGACY_ALLOWED_TYPES = { # Infrastructure / ops (fleet maps, system docs) "Machine", "Network", "Service", "Session", "Project", "Repo", "Credential", "Path", "Process", # Domain-neutral (newsrooms, research atlases, decision logs) "Concept", "Decision", "Event", "Person", "Org", "Source", # Catch-all "Reference", } TRUST_SIGNAL_TYPES = { # Upstream v0.2: a sanctioned computation plus how to check a run of it # (see check_attested_computation). Kept as the literal upstream spelling # (with a space), not renamed to fit a no-space convention. "Attested Computation", } ALLOWED_TYPES = LEGACY_ALLOWED_TYPES | TRUST_SIGNAL_TYPES ALLOWED_STATUSES = {"draft", "stable", "deprecated"} RESERVED = {"index.md", "log.md"} # okf_version values this validator accepts. The last entry is the current format # version, but it is opt-in, not the scaffold default -- scaffold.py still writes # "0.3" unless --trust-signals asks for "0.4" (see scaffold.py's own default). # Older entries stay supported so a newer validator still reads an older bundle. # Adding allowed types is backward compatible and bumps the format version # (0.1 -> 0.2). Accepting a datetime in timestamp changes the field grammar and # bumps it again (0.2 -> 0.3). Renaming 'verified' to 'verified_on' and adopting # the optional upstream-v0.2 trust fields bumps it once more (0.3 -> 0.4). SUPPORTED_VERSIONS = ("0.1", "0.2", DATETIME_TIMESTAMP_VERSION, TRUST_SIGNALS_VERSION) SPEC_VERSION = SUPPORTED_VERSIONS[-1] # current (opt-in) format version -- see note above def required_keys_for(bundle_version): """The required-key tuple for a declared okf_version. Only TRUST_SIGNALS_VERSION ("0.4") renames 'verified' to 'verified_on'; every other declared (or missing/unsupported) version keeps the legacy name, so an unsupported-version bundle is still checked against a sensible required set instead of crashing before the version-gate error is reported. """ return REQUIRED_KEYS_V04 if bundle_version == TRUST_SIGNALS_VERSION else REQUIRED_KEYS_LEGACY def allowed_types_for(bundle_version): """The closed type vocabulary for a declared okf_version.""" return ALLOWED_TYPES if bundle_version == TRUST_SIGNALS_VERSION else LEGACY_ALLOWED_TYPES def date_keys_for(bundle_version): """The date-checked key names for a declared okf_version (see required_keys_for).""" return ("verified_on", "timestamp") if bundle_version == TRUST_SIGNALS_VERSION else ("verified", "timestamp") def supports_datetime_timestamp(bundle_version): """Whether a supported bundle version carries full timestamp precision.""" try: return ( SUPPORTED_VERSIONS.index(bundle_version) >= SUPPORTED_VERSIONS.index(DATETIME_TIMESTAMP_VERSION) ) except ValueError: return False # Inline markdown link. The destination group allows one level of balanced # parens so a filename like `missing(v2).md` is still captured (a plain [^)]+ # would stop at the first ')' and skip the link entirely). LINK_RE = re.compile(r"\[[^\]]*\]\(((?:[^()]|\([^()]*\))*)\)") # OKF links are relative markdown links only. The [[slug]] wikilink idiom (from the # auto-memory system) is not OKF, so a typo'd or deleted [[ref]] would otherwise pass # the link check unseen. It is reported as an error. Checked against strip_code output, # so a [[x]] shown inside a code fence is illustrative, not flagged. WIKILINK_RE = re.compile(r"\[\[([^\[\]]+)\]\]") # Secret-value detectors. These match credential VALUES, not the key names/paths # a credential concept is allowed to document. The generic assignment pattern # requires a separator (`:`/`=`) directly before a high-entropy blob, so a # documented key name like `service/api/...-secret` does not trip it. # # The key labels that mark a value as a credential, shared by the generic base64 # assignment pattern and the opt-in entropy scan so the two agree on what counts # as a labeled secret. SECRET_LABEL = ( r"(?:password|passwd|secret|api[_-]?key|apikey|client[_-]?secret" r"|access[_-]?token|auth[_-]?token)") SECRET_PATTERNS = [ ("Tailscale key", re.compile(r"tskey-(?:api|auth|client)-[A-Za-z0-9]+-[A-Za-z0-9]{10,}")), ("private-key block", re.compile(r"-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----")), ("AWS access key id", re.compile(r"\bAKIA[0-9A-Z]{16}\b")), ("Google API key", re.compile(r"\bAIza[0-9A-Za-z_\-]{35}\b")), ("Slack token", re.compile(r"\bxox[baprs]-[0-9A-Za-z-]{10,}")), ("GitHub token", re.compile(r"\bgh[pousr]_[0-9A-Za-z]{36,}\b")), ("GitHub fine-grained PAT", re.compile(r"\bgithub_pat_[0-9A-Za-z_]{22,}\b")), # More provider tokens with fixed literal prefixes and exact or narrow shapes. # Providers whose token bodies allow path-like hyphens and underscores use the # entropy-gated patterns below instead. ("Stripe secret key", re.compile(r"\b[sr]k_(?:live|test)_[0-9A-Za-z]{24,}\b")), ("Stripe organization key", re.compile(r"\bsk_org_[0-9A-Za-z]{24,}\b")), ("Stripe webhook secret", re.compile(r"\bwhsec_[0-9A-Za-z]{32,}\b")), # GitLab documents this exact cookie label as a token prefix. Keep the value # shape narrow enough that its `_gitlab_session=...` documentation placeholder # stays clean while an actual serialized cookie is caught. ("GitLab session cookie", re.compile( r"(?i)\b_gitlab_session\s*=\s*['\"]?" r"[0-9A-Za-z%+/_=-]{20,}(?![0-9A-Za-z%+/_=.\-])")), ("npm token", re.compile(r"\bnpm_[0-9A-Za-z]{36}\b")), ("SendGrid API key", re.compile(r"\bSG\.[0-9A-Za-z_-]{22}\.[0-9A-Za-z_-]{43}")), # Legacy personal OpenAI keys carry no project/service segment. `sk-` alone is a # weak prefix, so the 40-char solid-base62 run does the signal work; it stays # disjoint from the entropy-gated project-key detector below, whose `-` breaks # the run. ("OpenAI legacy key", re.compile(r"\bsk-[0-9A-Za-z]{40,}\b")), ("secret assignment", re.compile( r"(?i)" + SECRET_LABEL + # Base64-standard value charset only -- deliberately excludes - and _. A # credential concept documents key paths like `secret: svc/api/prod-key-path`, # and a hyphen/underscore-rich path must not read as a high-entropy value. # Structured tokens that use -/_ (fine-grained PATs, Slack, etc.) have their # own specific patterns above; the opt-in entropy scan below covers the rest. r"\s*[:=]\s*['\"]?[A-Za-z0-9+/]{24,}['\"]?")), ] # Some provider token bodies allow the same hyphens and underscores used in # human-readable vault paths. A prefix alone would therefore flag documentation # such as `openai/sk-proj-production-primary-key-path`. These patterns capture a # complete base64url-like body. They still run by default because the provider # prefix is strong, but the entropy gate keeps path documentation clean. OpenAI # and Anthropic use the generic 4.0 bits/character floor. GitLab accepts 20-char # bodies, whose repeated characters lower their observable entropy; its narrower # floor is 88% of the maximum entropy observable at the captured body length, # capped at 4.0. That is 3.80 bits/character at 20 chars and rises to 4.0 at 24, # so short tokens are not judged against a longer sample's ceiling while the # longer path regressions stay clean. # # GitLab prefixes are from its token overview (checked 2026-07-23): # https://docs.gitlab.com/security/tokens/#token-prefixes PREFIXED_SECRET_PATTERNS = [ ("GitLab token", re.compile( r"\b(?:glpat|gloas|gldt|glrtr?|glcbt|glptt|glft|glimt|glagent|glwt" r"|glsoat|glffct)-([0-9A-Za-z_-]{20,})(?![0-9A-Za-z_/-])"), 0.88), ("Anthropic API key", re.compile( r"\bsk-ant-([0-9A-Za-z_-]{20,})(?![0-9A-Za-z_/-])"), 1.0), ("OpenAI project key", re.compile( r"\bsk-(?:proj|svcacct)-([0-9A-Za-z_-]{20,})(?![0-9A-Za-z_/-])"), 1.0), ] # Opt-in entropy scan (--secret-entropy-scan). The generic assignment pattern # above uses a base64-standard value charset that excludes - and _, so a labeled # secret whose value is URL-safe (base64url: - _ =) slips past it. Widening that # charset would re-flag OKF key paths like `secret: svc/api/prod-key-path`, the # precision an earlier review round asked us to keep. This optional pass instead # matches only the base64url charset -- base64-standard values with `/` stay the # generic pattern's job -- and keeps precision two ways. Structurally (the primary # guard), the captured run must be a complete token: the trailing lookahead rejects # a run that is followed by another value char (we truncated a longer token) or a # `/` (it is a path segment, not a standalone value), so a documented key path like # `secret: prd-usw2-...-key-path/service` cannot leak its first segment as a value. # Excluding `/` from the class alone did not do this -- it stopped the match at the # separator but still captured a >=24-char leading segment. Statistically, a # Shannon-entropy floor backstops the slashless case: a random token scores above a # short dictionary-and-separator name (measured: 24-char base64url secrets land # ~4.05-4.4 bits/char, short human-readable names stay under 4.0). The floor is # imperfect -- a long, varied slashless name can clear it, the acknowledged # precision-for-recall tradeoff -- which is why the structural check, not this # threshold, is the primary guard. It is off by default so a normal run keeps the # narrow, zero-false-positive base64 behavior; the flag trades some precision for # recall. SECRET_ENTROPY_RE = re.compile( r"(?i)" + SECRET_LABEL + r"\s*[:=]\s*['\"]?([A-Za-z0-9_=-]{24,})" r"(?![A-Za-z0-9_=/-])['\"]?") SECRET_ENTROPY_MIN_BITS = 4.0 def shannon_entropy(s: str) -> float: """Shannon entropy of s in bits per character (0.0 for the empty string).""" if not s: return 0.0 n = len(s) return -sum((c / n) * math.log2(c / n) for c in Counter(s).values()) def prefixed_secret_labels(text: str): """Yield each provider label with a complete, random-looking token body.""" for label, pattern, max_entropy_fraction in PREFIXED_SECRET_PATTERNS: for match in pattern.finditer(text): body = match.group(1) # A sample of n characters cannot exhibit more than log2(n) bits of # entropy per character, even when every character is unique. min_entropy = min( SECRET_ENTROPY_MIN_BITS, max_entropy_fraction * math.log2(len(body)), ) if shannon_entropy(body) >= min_entropy: yield label break def entropy_secret_values(text: str): """Yield the labeled, high-entropy base64url values in text that the generic base64 pattern misses. A value must carry a base64url-only character (- _ =) -- otherwise the generic pattern already covers it -- and clear the entropy floor, so a low-entropy hyphenated name is left alone.""" for m in SECRET_ENTROPY_RE.finditer(text): value = m.group(1) if not any(ch in value for ch in "-_="): continue # plain base64/alnum -- already covered by SECRET_PATTERNS if shannon_entropy(value) >= SECRET_ENTROPY_MIN_BITS: yield value FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---\n?(.*)$", re.DOTALL) def parse_frontmatter(text: str): """Return (frontmatter_dict_or_None, body). Raises yaml.YAMLError on bad YAML.""" if not text.startswith("---"): return None, text m = FRONTMATTER_RE.match(text) if not m: return None, text return yaml.safe_load(m.group(1)) or {}, m.group(2) def frontmatter_block(text: str) -> str | None: """Return the raw YAML frontmatter text (between the --- fences), or None. Used to enforce rules on the source text before YAML strips comments (see check_source_quoting).""" if not text.startswith("---"): return None m = FRONTMATTER_RE.match(text) return m.group(1) if m else None def link_destination(raw: str) -> tuple[str, str]: """Pull the path and fragment out of a markdown link's (...) contents: strip <...> wrapping and an optional "title"/'title'. Markdown allows [text](dest "title") and [text](<dest with spaces>), treating the whole contents as the path would falsely flag those as dangling.""" s = raw.strip() if s.startswith("<"): end = s.find(">") if end != -1: destination = s[1:end].strip() path, separator, fragment = destination.partition("#") return path, separator + fragment s = s.split(None, 1)[0] if s else s # dest ends at first space; rest is a title path, separator, fragment = s.partition("#") return path, separator + fragment def resolve_link(target: str, md_file: Path) -> Path: """Resolve a relative link destination against the file's directory. .resolve() collapses ../ so the bundle-boundary check is not fooled by a path like ../../outside.md.""" return (md_file.parent / target).resolve() def is_rooted_link(target: str) -> bool: """True for POSIX roots, Windows roots, drive paths, and UNC paths.""" windows_path = PureWindowsPath(target) return target.startswith(("/", "\\")) or bool(windows_path.drive) def real_case_path( dest: Path, bundle: Path, *, allow_nonconforming_md: bool = False ) -> Path | None: """The path on disk that `dest` names, ignoring case, or None if nothing matches. Returns `dest` unchanged when every component already matches the real name. Path.exists() asks the filesystem, and macOS answers yes for `Concepts/Foo.md` when the file is really `concepts/foo.md`. A bundle written there passes validation on the author's machine and dangles the first time Linux CI or a Linux reader opens it, which is the class of break this validator exists to catch before it ships. So walk the components against the real directory listings instead of asking whether the path exists. The walk doubles as the existence check: a component that matches nothing, case or no case, means the link dangles. Walk the link's spelling before Path.resolve() can canonicalize its case on Windows. Keep each symlink and '..' component in order: collapsing '..' lexically can change the destination after a symlinked directory. `dest` is the uncollapsed link path starting at `bundle`. The caller keeps a separately resolved path for the bundle-boundary check. """ current = bundle for part in dest.relative_to(bundle).parts: if part == "..": current = current / part continue try: names = set(os.listdir(current)) except OSError: return None # not a directory, or unreadable: nothing below it resolves if part in names: current = current / part continue # Exactly one case-variant is a mismatch worth naming. Several means a # case-sensitive filesystem holding both, and no way to say which was meant. # Do not recommend a file with an uppercase .md extension as a link fix: that # filename is rejected elsewhere in this validator. The caller can opt into a # second lookup solely to give that non-conforming file a rename diagnostic. variants = [n for n in names if n.lower() == part.lower()] if not allow_nonconforming_md and Path(part).suffix.lower() == ".md": variants = [n for n in variants if Path(n).suffix == ".md"] if len(variants) != 1: return None current = current / variants[0] return current def strip_code(text: str) -> str: """Blank out fenced code blocks and inline code spans so a link shown as an example (e.g. a ```md fence containing [x](sample.md)) is not mistaken for a real bundle link. The secret scan still runs on the raw text, a secret in a code block is still a leak. Heuristic, not a full CommonMark parser: it handles ```/~~~ fences (matching the closing fence's char and length, so a longer fence can wrap a shorter one) and backtick-run inline spans (``code with a ` inside``). Rare forms, 4-space indented code blocks, code spans spanning lines, are out of scope; an OKF concept that needs those can wrap the example in a fence.""" out = [] fence = None # (char, length) of the open fence, or None for line in text.splitlines(): stripped = line.lstrip() if fence is None: m = re.match(r"(`{3,}|~{3,})", stripped) if m: fence = (stripped[0], len(m.group(1))) out.append("") else: out.append(re.sub(r"(`+)(.+?)\1", "", line)) # drop inline code spans else: ch, length = fence m = re.match(r"(`{3,}|~{3,})\s*$", stripped) if m and stripped[0] == ch and len(m.group(1)) >= length: fence = None out.append("") return "\n".join(out) def _plain_scalar_dropped_comment(node, raw_fm): """True if a YAML comment directly truncated this scalar's provenance. `node` is a scalar node from the parsed frontmatter and `raw_fm` the raw text it was parsed from. Only a plain (unquoted) scalar can lose data: a quoted or block scalar (node.style set to ', ", |, or >) keeps a '#' as string content. For a plain scalar, YAML stops the value at the space before an inline '#', so the comment shows up in the raw text immediately after the scalar's end mark, on the same line. That is exactly the silent-truncation case (`issue #445` -> "issue") the SPEC quoting rule guards against; `issue#445` (no space) is one scalar and a '#' on a later line is a standalone comment, and neither trips this.""" if node.style is not None: return False j = node.end_mark.index while j < len(raw_fm) and raw_fm[j] in (" ", "\t"): j += 1 return j < len(raw_fm) and raw_fm[j] == "#" # PyYAML resolves both `<<` and an explicit `!!merge` tag to this tag; a key carrying it is a # merge directive regardless of how it is spelled, so it is never a real mapping key. _MERGE_TAG = "tag:yaml.org,2002:merge" def _child_ref_counts(root): """Map id(node) -> how many times it is referenced as a child across the tree. A YAML alias makes the composer reuse the anchor's node object, so an alias TARGET is the one node referenced 2+ times. Each unique node is traversed once (guarded by a seen set) so a recursive anchor cannot loop; references are still counted with multiplicity.""" counts: dict[int, int] = {} seen: set[int] = set() stack = [root] while stack: node = stack.pop() if id(node) in seen: continue seen.add(id(node)) children: list = [] if isinstance(node, yaml.MappingNode): for k, v in node.value: children += (k, v) elif isinstance(node, yaml.SequenceNode): children += list(node.value) for c in children: counts[id(c)] = counts.get(id(c), 0) + 1 stack.append(c) return counts def _value_uses_alias(val_node, counts): """True if any node in this value's subtree is reached via a YAML alias. An alias target is the same node object referenced 2+ times in the whole frontmatter (counts), or reached twice while walking this one subtree (a cycle). An unused anchor is referenced once, so an anchored literal is not flagged, consistent with allowing `source: [&p "x"]`. Rejecting alias USES closes #169: an aliased scalar shares its anchor's position marks, so the end-mark quoting check cannot see a comment dropped after the alias.""" seen: set[int] = set() stack = [val_node] while stack: node = stack.pop() if id(node) in seen: return True # reached twice within source -> an alias points back into it seen.add(id(node)) if counts.get(id(node), 0) >= 2: return True # shared with another reference -> an alias target if isinstance(node, yaml.MappingNode): for k, v in node.value: stack += (k, v) elif isinstance(node, yaml.SequenceNode): stack += list(node.value) return False def _mapping_yields_source(node, seen): """True if this mapping node's effective keys include 'source', counting keys reached through its own (possibly nested) merge keys. A merged mapping can itself merge in another mapping ('<<: *d' where d is '<<: {source: ...}'), so checking only the immediate keys misses a source that safe_load still materializes. Recurse through each merge key's mapping(s). The seen set guards a recursive anchor (&d {<<: *d}) from looping.""" if not isinstance(node, yaml.MappingNode) or id(node) in seen: return False seen.add(id(node)) for key, val in node.value: if not isinstance(key, yaml.ScalarNode): continue if key.tag == _MERGE_TAG: for merged in (val.value if isinstance(val, yaml.SequenceNode) else [val]): if _mapping_yields_source(merged, seen): return True elif key.value == "source": return True return False def _merge_supplies_source(root): """True if a top-level YAML merge key (<<) merges in a mapping that yields its own 'source' key, directly or through a further nested merge. YAML lets a literal 'source:' override a merged one, so a source smuggled in through '<<: {source: ...}' (or a chain of merges that resolves to source) beside a literal is silently dropped and the top-level source-key scan (which keys on literal 'source' nodes) never sees it. A merge value is a mapping, or a sequence of mappings ('<<: [*a, *b]'); compose resolves an alias to the shared node, so an aliased merge map is a MappingNode here. Detecting it lets a merge-supplied source be rejected whether or not a literal source sits beside it.""" for key, val in root.value: if not (isinstance(key, yaml.ScalarNode) and key.tag == _MERGE_TAG): continue for node in (val.value if isinstance(val, yaml.SequenceNode) else [val]): if _mapping_yields_source(node, set()): return True return False def check_source_quoting(rel, fm, raw_fm, errors): """Enforce the SPEC 'source' quoting rule, where YAML's comment stripping would otherwise silently drop part of a provenance pointer. Quoting source elements is a hard SPEC rule, but YAML drops an unquoted inline '#' comment with no error: `- issue #445` parses to "issue", losing "#445". The parsed value alone cannot reveal the loss, so this re-parses the frontmatter into its node tree (which carries source position marks) and, for every top-level `source` element, checks whether a comment directly truncated a plain scalar (see _plain_scalar_dropped_comment). Delegating the lexing to YAML covers every shape, block items, single- and multi-line flow lists, wrapped scalars, quoted strings with escapes, anchors/tags, and block scalars, without re-implementing the parser. It is scoped to the top-level `source` key only (a nested `source:` under other metadata is not the OKF provenance list). A real parse error is reported by the schema check. `fm` is the safe_load result the caller already parsed. A `source` can enter it through a YAML merge key (`<<: {source: *r}`), a whole-node alias, an alias in key position, or a duplicate `source` key, each materializes the field, but YAML keeps the LAST of duplicate keys, so the value safe_load returns is not necessarily the first clean `source:` the scan finds. OKF source must be one literal top-level list, so this requires the effective source to come from exactly one literal, unshared `source:` key and rejects every indirection (merge, alias, duplicate) up front, which also keeps the node-tree quoting scan total.""" if not raw_fm: return try: root = yaml.compose(raw_fm, Loader=yaml.SafeLoader) except yaml.YAMLError: return if not isinstance(root, yaml.MappingNode): return counts = _child_ref_counts(root) # YAML keeps the LAST of duplicate keys, so the value safe_load returns for `source` is # decided by the last top-level key that resolves to "source", not the first clean one. # Collect every such key. An alias in key position (`*k` resolving to "source") makes # compose reuse the anchor's node, so the key reads as a "source" scalar while the written # key is an alias, count >= 2 marks that sharing, so an aliased key is not unshared. A key # SPELLED "source" but carrying the explicit merge tag (`!!merge source:`) is a merge # directive, not a source key, PyYAML classifies merge by the tag, not the spelling, so # exclude it here: counting it would both falsely reject a file whose only real source is a # separate literal, and let a source merged in through it bypass the scan below. source_keys = [ (k, v) for k, v in root.value if isinstance(k, yaml.ScalarNode) and k.value == "source" and k.tag != _MERGE_TAG ] unshared = [(k, v) for k, v in source_keys if counts.get(id(k), 0) < 2] if fm.get("source") is not None and ( len(source_keys) != 1 or not unshared or _merge_supplies_source(root) ): errors.append( f"{rel}: 'source' is not a single literal top-level 'source:' key, it enters " f"through a YAML merge key (<<), an alias, or a duplicate 'source' key; OKF " f"'source' must be one literal top-level list of provenance pointers, declare " f"it directly instead of merging, aliasing, or duplicating it") return for key_node, val_node in source_keys: # A YAML anchor/alias that entangles source with the rest of the frontmatter # shares node identity and position marks, so the dropped-comment scan below # reads the anchor's definition line, not the use site, and can miss a truncated # pointer (#169). OKF source is a flat list of literal, self-contained pointers # anyway, so any anchor/alias SHARING is invalid here, reject it, which keeps the # quoting scan total. An unused anchor definition (`[&p "x"]`) shares nothing and # stays allowed. if _value_uses_alias(val_node, counts): errors.append( f"{rel}: a top-level 'source' value shares a YAML anchor/alias (& or *) " f"with the rest of the frontmatter; OKF 'source' must be a flat list of " f"literal provenance pointers, write each pointer out literally instead " f"of anchoring or aliasing it") return if isinstance(val_node, yaml.SequenceNode): scalars = [n for n in val_node.value if isinstance(n, yaml.ScalarNode)] elif isinstance(val_node, yaml.ScalarNode): scalars = [val_node] else: scalars = [] if any(_plain_scalar_dropped_comment(n, raw_fm) for n in scalars): errors.append( f"{rel}: a top-level 'source' element has an unquoted '#' that YAML " f"reads as a comment, dropping the rest of the pointer, quote each " f"source element that contains a '#'") return # one report per concept is enough ISO_DATE_RE = re.compile(r"[0-9]{4}-[0-9]{2}-[0-9]{2}\Z") ISO_DATETIME_RE = re.compile( r"[0-9]{4}-[0-9]{2}-[0-9]{2}[T ][0-9]{2}:[0-9]{2}:[0-9]{2}" r"(?:[.,][0-9]+)?(?:Z|[+-][0-9]{2}:[0-9]{2})?\Z" ) def _is_iso_date(s): """True only for the literal YYYY-MM-DD form required by the SPEC.""" if not ISO_DATE_RE.fullmatch(s): return False try: dt.datetime.strptime(s, "%Y-%m-%d") except ValueError: return False return True def _is_iso_datetime(s): """True for the SPEC's full ISO 8601 datetime spelling. ``fromisoformat`` checks calendar and clock ranges, but it is deliberately not the lexical contract: both it and PyYAML accept wider timestamp forms. The regular expression first requires the exact zero-padded source shape. A trailing ``Z`` is normalised for Python versions that do not parse it. """ if not ISO_DATETIME_RE.fullmatch(s): return False try: dt.datetime.fromisoformat(s[:-1] + "+00:00" if s.endswith("Z") else s) except ValueError: return False return True def _literal_scalar_node(raw_fm, *path): """Return an unshared literal scalar node along a mapping/sequence path. String path components select the effective (last) literal mapping key; integer components index a sequence. Aliases are rejected because their source marks describe the anchor rather than the use site. """ if not raw_fm or not path: return None try: root = yaml.compose(raw_fm, Loader=yaml.SafeLoader) except yaml.YAMLError: return None if not isinstance(root, yaml.MappingNode): return None counts = _child_ref_counts(root) node = root for component in path: if isinstance(component, str): if not isinstance(node, yaml.MappingNode): return None candidates = [ (key_node, val_node) for key_node, val_node in node.value if isinstance(key_node, yaml.ScalarNode) and key_node.value == component and key_node.tag != _MERGE_TAG and counts.get(id(key_node), 0) < 2 ] if not candidates: return None _, node = candidates[-1] # safe_load keeps the last duplicate key elif isinstance(component, int): if (not isinstance(node, yaml.SequenceNode) or component < 0 or component >= len(node.value)): return None node = node.value[component] else: return None if counts.get(id(node), 0) >= 2: return None return node if isinstance(node, yaml.ScalarNode) else None def _raw_mapping_scalar(raw_fm, *path): """Return a literal scalar's unnormalised text along a node path, or None. ``safe_load`` constructs a broad family of YAML timestamp spellings as ``date``/``datetime`` objects. Calling ``isoformat`` on those objects would silently turn a malformed source spelling into a conforming value. Compose the same frontmatter into a node tree and inspect each effective (last) literal key along the path instead. Simple quoted strings remain supported; YAML aliases, tags, block scalars, and escape-based spellings are not literal date fields. """ node = _literal_scalar_node(raw_fm, *path) if node is None: return None written = raw_fm[node.start_mark.index:node.end_mark.index] if node.style is None and written == node.value: return written if node.style in ("'", '"') and written == node.style + node.value + node.style: return node.value return None def _mapping_scalar_dropped_comment(raw_fm, *path): """True when a literal scalar at path was truncated by a YAML comment.""" node = _literal_scalar_node(raw_fm, *path) return node is not None and _plain_scalar_dropped_comment(node, raw_fm) def _raw_top_level_scalar(raw_fm, key): """Return a literal top-level scalar's unnormalised text, or None.""" return _raw_mapping_scalar(raw_fm, key) def check_dates(rel, fm, raw_fm, bundle_version, errors): accepts_datetime = supports_datetime_timestamp(bundle_version) for key in date_keys_for(bundle_version): val = fm.get(key) if val is None: continue # missing/empty already reported by required-key check raw = _raw_top_level_scalar(raw_fm, key) if raw is not None and _is_iso_date(raw): continue # `timestamp` is upstream OKF's key and upstream writes it as a full ISO # 8601 datetime. Version 0.3 and later accept and carry that precision; # older formats remain date-only. `verified` is this spec's own key and # always stays date-only because a time of day invites false precision. if (key in DATETIME_KEYS and accepts_datetime and raw is not None and _is_iso_datetime(raw)): continue if key in DATETIME_KEYS and not accepts_datetime: expected = ( f"an ISO date YYYY-MM-DD under okf_version {bundle_version or '<missing>'}; " f"full ISO 8601 datetimes require okf_version {DATETIME_TIMESTAMP_VERSION}" ) elif key in DATETIME_KEYS: expected = "an ISO date YYYY-MM-DD or a full ISO 8601 datetime" else: expected = "an ISO date YYYY-MM-DD" shown = raw if raw is not None else val errors.append(f"{rel}: '{key}' must be {expected}, got {shown!r}") def check_lists(rel, fm, errors): for key in LIST_KEYS: val = fm.get(key) if val is None: continue # required-key check handles absence if not isinstance(val, list): errors.append(f"{rel}: '{key}' must be a YAML list, got {type(val).__name__}") continue if key == "source" and not val: errors.append(f"{rel}: 'source' must be a non-empty list of provenance pointers") for el in val: if not isinstance(el, str) or not el.strip(): errors.append(f"{rel}: '{key}' has a non-string/empty element {el!r}") def check_generated(rel, fm, raw_fm, errors): """Optional upstream-v0.2 'generated' field: {by, at} -- who/what produced the current content and when.""" if "generated" not in fm: return val = fm["generated"] if not isinstance(val, dict): errors.append(f"{rel}: 'generated' must be a mapping {{by, at}}, got {type(val).__name__}") return by = val.get("by") if not isinstance(by, str) or not by.strip(): errors.append(f"{rel}: 'generated.by' must be a non-empty string") at = val.get("at") if at is None: errors.append(f"{rel}: 'generated.at' is required when 'generated' is present") return raw = _raw_mapping_scalar(raw_fm, "generated", "at") if raw is None or not (_is_iso_date(raw) or _is_iso_datetime(raw)): shown = raw if raw is not None else at errors.append(f"{rel}: 'generated.at' must be an ISO date or a full ISO 8601 datetime, got {shown!r}") def check_verified_trust(rel, fm, raw_fm, bundle_version, errors): """Optional upstream-v0.2 'verified' field: a list of independent {by, at} confirmations, from which a consumer derives a trust tier (unverified / machine-confirmed / human-reviewed). Only meaningful at TRUST_SIGNALS_VERSION, where 'verified' is not the required key (see REQUIRED_KEYS_V04's 'verified_on') -- at every earlier version 'verified' IS the required legacy single-date field, already checked by check_dates, so this must not also re-validate it there under the new shape.""" if bundle_version != TRUST_SIGNALS_VERSION: return if "verified" not in fm: return # optional; absence reads as "unverified" to a consumer, not an error val = fm["verified"] if not isinstance(val, list) or not val: errors.append( f"{rel}: 'verified' (trust confirmations) must be a non-empty YAML list " f"of {{by, at}} mappings when present, got {type(val).__name__}") return for i, entry in enumerate(val): if not isinstance(entry, dict): errors.append(f"{rel}: 'verified[{i}]' must be a mapping with 'by' and 'at', got {type(entry).__name__}") continue by = entry.get("by") if not isinstance(by, str) or not by.strip(): errors.append(f"{rel}: 'verified[{i}].by' must be a non-empty string") at = entry.get("at") if at is None: errors.append(f"{rel}: 'verified[{i}].at' is required") else: raw = _raw_mapping_scalar(raw_fm, "verified", i, "at") if raw is None or not _is_iso_date(raw): shown = raw if raw is not None else at errors.append( f"{rel}: 'verified[{i}].at' must be an ISO date YYYY-MM-DD, " f"got {shown!r}") def check_sources_plural(rel, fm, raw_fm, errors): """Optional upstream-v0.2 'sources' field (plural) -- structured provenance objects, distinct from the required singular 'source' (a flat list of quoted pointers). Each entry needs a unique 'id' and a 'resource'; title/author/ usage_count/last_modified are optional credibility signals. Shape-checked only -- this does not cross-check in-body [^id] footnotes against these ids (see SPEC.md).""" if "sources" not in fm: return val = fm["sources"] if not isinstance(val, list) or not val: errors.append( f"{rel}: 'sources' must be a non-empty YAML list of provenance objects " f"when present, got {type(val).__name__}") return seen_ids = set() for i, entry in enumerate(val): if not isinstance(entry, dict): errors.append(f"{rel}: 'sources[{i}]' must be a mapping, got {type(entry).__name__}") continue sid = entry.get("id") if not isinstance(sid, str) or not sid.strip(): errors.append(f"{rel}: 'sources[{i}].id' must be a non-empty string") elif sid in seen_ids: errors.append( f"{rel}: 'sources[{i}].id' {sid!r} duplicates an earlier entry -- " f"ids must be unique within 'sources'") else: seen_ids.add(sid) resource = entry.get("resource") if not isinstance(resource, str) or not resource.strip(): errors.append(f"{rel}: 'sources[{i}].resource' must be a non-empty string") elif _mapping_scalar_dropped_comment(raw_fm, "sources", i, "resource"): errors.append( f"{rel}: 'sources[{i}].resource' has an unquoted '#' that YAML " f"reads as a comment, dropping the rest of the provenance pointer") for opt_key in ("title", "author"): v = entry.get(opt_key) if v is not None and (not isinstance(v, str) or not v.strip()): errors.append(f"{rel}: 'sources[{i}].{opt_key}' must be a non-empty string when present") uc = entry.get("usage_count") if uc is not None and (not isinstance(uc, int) or isinstance(uc, bool) or uc < 0): errors.append(f"{rel}: 'sources[{i}].usage_count' must be a non-negative integer when present, got {uc!r}") lm = entry.get("last_modified") if lm is not None: raw = _raw_mapping_scalar(raw_fm, "sources", i, "last_modified") if raw is None or not _is_iso_date(raw): shown = raw if raw is not None else lm errors.append( f"{rel}: 'sources[{i}].last_modified' must be an ISO date " f"YYYY-MM-DD when present, got {shown!r}") def check_status(rel, fm, errors): """Optional upstream-v0.2 'status' field: draft/stable/deprecated. Absent means stable (nothing to check).""" if "status" not in fm: return val = fm["status"] if not isinstance(val, str) or val not in ALLOWED_STATUSES: errors.append(f"{rel}: 'status' must be one of {sorted(ALLOWED_STATUSES)} when present, got {val!r}") def check_stale_after(rel, fm, raw_fm, errors): """Optional upstream-v0.2 'stale_after' field: an absolute ISO date, deliberately not a relative TTL (see SPEC.md).""" if "stale_after" not in fm: return val = fm["stale_after"] raw = _raw_mapping_scalar(raw_fm, "stale_after") if raw is None or not _is_iso_date(raw): shown = raw if raw is not None else val errors.append( f"{rel}: 'stale_after' must be an ISO date YYYY-MM-DD, got {shown!r}") def check_attested_computation(rel, fm, errors): """Upstream-v0.2 'Attested Computation' type: a sanctioned computation plus the means to check that a run of it actually matches. Checks shape only -- this validator never executes the computation, the executor, or the attester; that is a consumer's runtime job (see SPEC.md).""" if fm.get("type") != "Attested Computation": return runtime = fm.get("runtime") if not isinstance(runtime, str) or not runtime.strip(): errors.append(f"{rel}: 'Attested Computation' requires a non-empty 'runtime' string") params = fm.get("parameters") if not isinstance(params, list): errors.append(f"{rel}: 'Attested Computation' requires a 'parameters' list") else: for i, p in enumerate(params): if not isinstance(p, dict): errors.append(f"{rel}: 'parameters[{i}]' must be a mapping {{name, type, required}}, got {type(p).__name__}") continue extra = sorted(set(p) - {"name", "type", "required"}) if extra: errors.append(f"{rel}: 'parameters[{i}]' has undeclared keys {extra}") for key in ("name", "type"): v = p.get(key) if not isinstance(v, str) or not v.strip(): errors.append(f"{rel}: 'parameters[{i}].{key}' must be a non-empty string") if not isinstance(p.get("required"), bool): errors.append(f"{rel}: 'parameters[{i}].required' must be true or false") executor = fm.get("executor") if not isinstance(executor, dict): errors.append(f"{rel}: 'Attested Computation' requires an 'executor' mapping {{resource, receipt}}") else: resource = executor.get("resource") if not isinstance(resource, str) or not resource.strip(): errors.append(f"{rel}: 'executor.resource' must be a non-empty string") receipt = executor.get("receipt") if not isinstance(receipt, list) or not receipt or not all( isinstance(r, str) and r.strip() for r in receipt): errors.append(f"{rel}: 'executor.receipt' must be a non-empty list of non-empty strings") attester = fm.get("attester") if not isinstance(attester, dict): errors.append(f"{rel}: 'Attested Computation' requires an 'attester' mapping {{resource}}") else: resource = attester.get("resource") if not isinstance(resource, str) or not resource.strip(): errors.append(f"{rel}: 'attester.resource' must be a non-empty string") def declared_bundle_version(bundle): """Read the root marker for version-dependent field checks. This is a non-reporting pre-pass; the main file loop remains responsible for all root-index diagnostics. Reading it up front means a root-level concept named ``a.md`` receives the right grammar even though it sorts before ``index.md``. """ root_index = bundle / "index.md" if not root_index.is_file(): return None try: fm, _ = parse_frontmatter(root_index.read_text(encoding="utf-8-sig")) except (OSError, yaml.YAMLError, ValueError): return None if not isinstance(fm, dict) or fm.get("okf_version") is None: return None return str(fm["okf_version"]).strip() def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--bundle", default="bundle", help="path to the OKF bundle directory (default: bundle)") ap.add_argument( "--secret-entropy-scan", action="store_true", help="also flag a labeled URL-safe/base64url value whose Shannon entropy " "clears the secret floor (opt-in; trades some precision for recall on " "hyphenated secret values the base64 pattern misses)") args = ap.parse_args() bundle = Path(args.bundle).resolve() if not bundle.exists(): print(f"FAIL: bundle not found at {bundle}") return 1 if not bundle.is_dir(): print(f"FAIL: bundle path is not a directory: {bundle}") return 1 # Discover markdown files case-insensitively (suffix .lower() == ".md") so a # non-conforming Foo.MD cannot hide from validation behind a case-sensitive glob; # it is found here and rejected below. rglob("*") also yields a directory named # like "archive.md"; reading one raises IsADirectoryError, so keep only real files # and report any .md-suffixed path that is a directory rather than crashing. md_entries = sorted(p for p in bundle.rglob("*") if p.suffix.lower() == ".md") md_files = [p for p in md_entries if p.is_file()] errors: list[str] = [ f"{p.relative_to(bundle)}: a '*.md' path must be a file, not a directory" for p in md_entries if not p.is_file() ] # A bundle must have a root index.md. The okf_version gate below only runs when # that file exists, so without this check a bundle that simply omits the root # index (or an empty directory) would validate clean and bypass version gating. if not (bundle / "index.md").is_file(): errors.append("index.md: bundle-root index is required and must declare okf_version") bundle_version = declared_bundle_version(bundle) type_counts: Counter = Counter() concepts = 0 for f in md_files: rel = f.relative_to(bundle) # utf-8-sig strips a leading byte-order mark if present. A BOM (common from # Windows editors) would otherwise defeat the startswith("---") frontmatter # check, reporting valid frontmatter as missing. text = f.read_text(encoding="utf-8-sig") # secret scan on every file, including index.md and a non-conforming Foo.MD # (a leak is a leak regardless of extension, scan before rejecting below). for label, pat in SECRET_PATTERNS: if pat.search(text): errors.append( f"{rel}: possible secret leak ({label}), remove the value, " f"document the key name/path instead") for label in prefixed_secret_labels(text): errors.append( f"{rel}: possible secret leak ({label}), remove the value, " f"document the key name/path instead") if args.secret_entropy_scan and next(entropy_secret_values(text), None): errors.append( f"{rel}: possible secret leak (high-entropy assignment flagged by " f"--secret-entropy-scan), remove the value, document the key " f"name/path instead") # OKF concept and index files use a lowercase .md extension. A non-lowercase # extension (Foo.MD) is non-conforming: it was discovered case-insensitively # above so its content is still secret-scanned, then rejected here instead of # validated as a concept. With the case-insensitive link check below, this # closes the bypass where an uppercase-extension file and links to it both # escaped validation. if f.suffix != ".md": errors.append( f"{rel}: non-conforming filename, OKF concept files use a lowercase " f"'.md' extension, found {f.suffix!r}; rename it to .md") continue try: fm, body = parse_frontmatter(text) except (yaml.YAMLError, ValueError) as e: # ValueError covers a date-shaped scalar PyYAML auto-constructs and # rejects (e.g. an invalid month), which is not a YAMLError subclass. errors.append( f"{rel}: YAML frontmatter parse error ({e.__class__.__name__}: {e}), " f"quote any string field that holds a YAML-significant character. " f"Common triggers: a colon-space (': ') anywhere in description, title, " f"or a source element; a bare '#' in a source element; an invalid date " f"in verified/timestamp.") continue # Past this point fm is either None or a mapping. Syntactically valid YAML # that is a list/scalar (e.g. a stray top-level list) would otherwise crash # the later fm.get(...) calls; report it as a clean failure instead. if fm is not None and not isinstance(fm, dict): errors.append(f"{rel}: frontmatter must be a YAML mapping, got {type(fm).__name__}") continue if f.name in RESERVED: if str(rel) == "index.md": # the bundle-root index.md may carry frontmatter, but only the # okf_version marker, never concept metadata or stray keys. keys = set(fm) if fm else set() if "okf_version" not in keys: errors.append("index.md: bundle-root index must declare okf_version in frontmatter") else: version = str(fm.get("okf_version")).strip() if version not in SUPPORTED_VERSIONS: errors.append( f"index.md: okf_version {fm.get('okf_version')!r} is not supported " f"(this validator supports {', '.join(SUPPORTED_VERSIONS)})") extra = sorted(keys - {"okf_version"}) if extra: errors.append(f"index.md: bundle-root index may carry only okf_version, found {extra}") elif fm is not None: # any other reserved file (subdir index.md, log.md) carries no frontmatter. errors.append(f"{rel}: reserved file should not carry frontmatter") continue if fm is None: errors.append(f"{rel}: missing YAML frontmatter") continue concepts += 1 ctype = fm.get("type", "<none>") # type must be a scalar string. A list/dict (a plausible YAML typo like # `type: [Reference]`) is unhashable and would crash both the Counter # increment and the closed-vocabulary membership test, so report it and # fall back to "<none>" to keep the rest of this concept's checks running. if not isinstance(ctype, str): errors.append(f"{rel}: 'type' must be a string, got {type(ctype).__name__}") ctype = "<none>" type_counts[ctype] += 1 for key in required_keys_for(bundle_version): val = fm.get(key) if val is None or (isinstance(val, str) and not val.strip()): errors.append(f"{rel}: missing/empty required frontmatter key '{key}'") allowed_types = allowed_types_for(bundle_version) if ctype not in allowed_types and ctype != "<none>": errors.append(f"{rel}: type '{ctype}' not in the spec vocab {sorted(allowed_types)}") check_lists(rel, fm, errors) raw_fm = frontmatter_block(text) check_dates(rel, fm, raw_fm, bundle_version, errors) check_source_quoting(rel, fm, raw_fm, errors) if bundle_version == TRUST_SIGNALS_VERSION: check_generated(rel, fm, raw_fm, errors) check_verified_trust(rel, fm, raw_fm, bundle_version, errors) check_sources_plural(rel, fm, raw_fm, errors) check_status(rel, fm, errors) check_stale_after(rel, fm, raw_fm, errors) check_attested_computation(rel, fm, errors) # Link resolution: every internal link to a .md file must resolve to a file # that exists inside the bundle. A link escaping the bundle root or pointing at # a missing file is a hard failure, the bundle is validated as one # self-contained tree. To validate federated content, assemble the bundles into # a single tree and point --bundle at that root. for f in md_files: if f.suffix != ".md": continue # non-conforming file already reported; don't pile on link errors text = strip_code(f.read_text(encoding="utf-8-sig")) for m in WIKILINK_RE.finditer(text): slug = m.group(1).strip() errors.append( f"{f.relative_to(bundle)}: '[[{slug}]]' is not an OKF link, use a " f"relative markdown link like [text]({slug}.md). The [[slug]] form is " f"the auto-memory convention, not OKF.") for raw in LINK_RE.findall(text): target, fragment = link_destination(raw) if not target: continue low = target.lower() # External/anchor links are out of scope. Lower-case the scheme test so an # uppercase scheme (HTTPS://...) is still recognized and skipped, not # resolved as a local path (which would falsely fail as escaping/dangling). if low.startswith(("http://", "https://", "mailto:", "#", "tel:")): continue # Match .md case-insensitively, the same way discovery now does, so a link # to an uppercase-extension file (ghost.MD) is checked for dangling/escape # instead of silently skipped. If such a target file exists it is separately # rejected as non-conforming above, so the two checks stay in agreement. if ".md" not in low: continue if is_rooted_link(target): errors.append(f"{f.relative_to(bundle)}: root-relative link not allowed " f"(use a relative path) -> {target}") continue spelled = f.parent / target real = real_case_path(spelled, bundle) if real is not None: try: resolved_real = real.resolve() except (OSError, RuntimeError): real = None else: if not resolved_real.is_relative_to(bundle): errors.append(f"{f.relative_to(bundle)}: link escapes bundle root -> {target}") continue try: dest = resolve_link(target, f) except (OSError, RuntimeError): errors.append(f"{f.relative_to(bundle)}: dangling link -> {target}") continue inside = dest == bundle or bundle in dest.parents if not inside and real is None: errors.append(f"{f.relative_to(bundle)}: link escapes bundle root -> {target}") else: if real is not None and not real.exists(): real = None if real is None: nonconforming = real_case_path( spelled, bundle, allow_nonconforming_md=True ) if nonconforming is not None: try: resolved_nonconforming = nonconforming.resolve() except (OSError, RuntimeError): nonconforming = None else: if not resolved_nonconforming.is_relative_to(
-
-
spec
-
SPEC.md 20.8 KB
# OKF spec v1 Open Knowledge Format (OKF) is a convention for storing knowledge as small markdown files that both people and agents can read. One file describes one concept and carries its own provenance. Directory `index.md` files provide navigation. A validator enforces the contract so the knowledge base stays consistent as it grows. This is the generic spec. A project may layer its own conventions on top (extra tags, naming patterns, a fixed section list), but must not weaken the rules below. ## A note on version numbers Three separate numbers show up in this project, and none of them track each other: 1. **Upstream Google OKF's own spec version**, `0.1` (June 2026), then `0.2` (July 2026, adding the trust/provenance/attestation vocabulary this document adopts below). This is Google's number, not this fork's. 2. **This skill's package version** (the `version:` field in `SKILL.md`'s frontmatter), its own release-numbering axis for the skill/plugin itself (bug fixes, secret-scanner hardening, Codex compatibility, and so on). It has no relationship to either spec version. 3. **This fork's own bundle-format version** (the `okf_version` marker every bundle-root `index.md` declares, and what `SUPPORTED_VERSIONS` in `validate.py` checks), `0.1`, then `0.2` (new allowed types), then `0.3` (datetime-form `timestamp`), and now `0.4` (this patch: the v0.2 trust/provenance fields below, and the required-key rename they force). So "OKF v0.2" (upstream Google's spec) and "`okf_version: 0.2`" (a bundle declared under *this fork's* second format revision, from months before Google's v0.2 existed) are two unrelated things that happen to share a digit. Where this document says "upstream v0.2" it means Google's; a bare "`okf_version: 0.4`" always means this fork's own marker. ## Relationship to upstream OKF This spec is a strict fork of Google's Open Knowledge Format ([GoogleCloudPlatform/knowledge-catalog](https://github.com/GoogleCloudPlatform/knowledge-catalog/tree/main/okf)). It keeps the core idea (knowledge as small markdown files with YAML frontmatter and `index.md` navigation) and tightens the contract so a validator can enforce it. If you already know upstream OKF, these are the intentional differences to author against; upstream conventions will otherwise produce files this validator rejects. - Required keys. Upstream requires only `type` and treats everything else as recommended, with any extra key allowed. Here, for a bundle declaring `okf_version` `0.1` through `0.3`, all seven of `type`, `title`, `description`, `source`, `verified`, `timestamp`, and `tags` are required and non-empty. For a bundle declaring `0.4`, `verified` is renamed to `verified_on` in that required list (see "Trust and provenance (upstream v0.2 vocabulary)" for why), so a `0.4` bundle requires `type`, `title`, `description`, `source`, `verified_on`, `timestamp`, and `tags` instead. Either way, a file that conforms upstream (say, `type` alone) fails validation here. - `resource` becomes `source`. Upstream's optional `resource` is one canonical URI for the underlying asset. This spec drops `resource` and requires `source`, a non-empty list of provenance pointers (paths, commands, URLs, events). Upstream v0.2 separately introduces its own richer `sources` (plural), this fork adopts that too, as an optional companion to the required `source`; see below. - Citations fold into `source`. Upstream lists sources under a `# Citations` heading and allows a `references/` subdirectory. Here there is neither; all provenance lives in the `source` frontmatter list (and, optionally, the richer `sources` list below). - `verified`/`verified_on` is added. Upstream v0.1 has no such key. Through `okf_version` `0.3`, this spec requires `verified`, the ISO date the fact was last confirmed true, kept distinct from `timestamp` (when the file was authored or edited). At `0.4`, this fork's own field is renamed `verified_on` to free the name `verified` for upstream v0.2's own `verified`, a different thing (see below). - `timestamp`'s datetime form. Upstream's `timestamp` is an ISO 8601 datetime, and `check_dates` accepts that form for `timestamp` in a `0.3`-or-later bundle (with or without an offset, including a trailing `Z`), so an upstream bundle validates here unchanged. A `0.1`/`0.2` bundle's `timestamp` stays date-only. `verified`/`verified_on` is always date-only, in every version: it records the day a fact was last confirmed true, and a time of day there is false precision about the confirmation. - The type vocab is closed. Upstream types are freeform and unregistered, and consumers must tolerate unknown ones. Here the set is fixed (see Type vocab) and an unlisted type fails the build, which catches typos; extending it is a deliberate spec edit. - Links are strict, and must be relative. Upstream treats a broken link as tolerable ("consumers MUST tolerate broken links") and permits bundle-root-relative targets like `/tables/customers.md`. Here every intra-bundle link must resolve or validation fails, the `[[slug]]` wikilink form is rejected outright, and any target beginning with `/` is rejected as a root-relative link, so an upstream `/tables/customers.md` must be rewritten relative to the file that links it. - A root `index.md` declaring `okf_version` is mandatory. Upstream treats `index.md` and `okf_version` as optional. Here the bundle root must contain an `index.md` whose frontmatter declares `okf_version` (and carries nothing else), so an upstream bundle with no root index, or one that omits the version marker, fails validation. - Secret values fail the build. Upstream OKF has no secret-scanning rule. Here `validate.py` scans every markdown file for private-key blocks, cloud-token shapes, and `secret=<value>` assignments and fails the bundle on a hit, so an upstream bundle that inlines a credential value passes upstream but is rejected here (see Security). - Trust/provenance/attestation fields are adopted, not required. Upstream v0.2 adds `generated`, `sources`, `status`, `stale_after`, `verified` (the new, upstream shape), and an `Attested Computation` type, all of it optional there, and all of it optional here too. Adopting none of it leaves a bundle exactly as valid as before; see the dedicated section below. This fork's own format version differs from upstream's on a separate axis (see "A note on version numbers" above): upstream is now `0.1`/`0.2`, this fork's current bundle-format version is `0.4` (the validator still accepts `0.1`, `0.2`, and `0.3`). Every difference above pulls the same direction: upstream stays minimal and needs no tooling to stay portable, while this fork adds a validator that fails the build when a bundle drifts. ## Bundle model A bundle is a directory tree. The simplest bundle is one directory of concept files with an `index.md`. Larger bundles group concepts into subdirectories by subject. ``` <bundle-root>/ index.md carries okf_version, here and nowhere else <section>/ index.md navigation for the section <concept>.md one concept per file ``` ## Files - The bundle-root `index.md` carries `okf_version` (one of `"0.1"`–`"0.4"`; `scaffold.py` still writes `"0.3"` by default, `"0.4"` only with `--trust-signals`) in frontmatter, only there. - Per-directory `index.md`: a heading, an optional one-line preamble, then bullet navigation. No frontmatter. (Keep the preamble to one line, it orients, it doesn't narrate.) - `log.md` (optional, per directory): dated entries, newest first, no frontmatter. - Concept files: one concept each, frontmatter required (below). - Reserved filenames: `index.md`, `log.md`. - All markdown files use a lowercase `.md` extension. A non-lowercase extension (`.MD`, `.Md`) is non-conforming and rejected, the validator discovers files case-insensitively so such a file cannot escape validation, and link checks match `.md` case-insensitively too. The validator enforces the frontmatter rules here, reserved files carry no frontmatter, and only the bundle-root `index.md` carries `okf_version` (and nothing else). The index/log body shapes above are recommendations for human readers, not validator-checked structure. The current format version is `0.4`, but `scaffold.py` still emits `0.3` by default, unchanged from before this patch, and only writes `0.4` when explicitly asked (`scaffold.py --trust-signals`; see Tooling). The validator accepts `0.1` through `0.4` either way, so a newer validator still reads an older bundle. Version `0.2` added allowed types. Version `0.3` admitted a full datetime in `timestamp`. Version `0.4` renames `verified` to `verified_on` (freeing `verified` for the new upstream-v0.2 shape) and adds the optional trust/provenance fields below, a bundle that adopts none of them still only needs to declare `0.4` because of the rename; one that keeps declaring `0.1`–`0.3` keeps the old `verified` field exactly as before and simply cannot use the new optional fields (see next section). The marker makes these grammar changes explicit, so an older validator reports a clear unsupported-version error instead of a misleading field error. ## Concept frontmatter (required keys, all non-empty) For a bundle declaring `okf_version` `0.1` through `0.3`: | key | value | | --- | --- | | `type` | one of the type vocab below | | `title` | the concept name | | `description` | one line | | `source` | YAML list of provenance pointers (paths, commands, URLs, events) | | `verified` | ISO date `YYYY-MM-DD` the fact was last confirmed true (see note below) | | `timestamp` | ISO date authored/updated, or in a `0.3` bundle a full ISO 8601 datetime | | `tags` | YAML list | For a bundle declaring `okf_version` `0.4`, the table is identical except `verified` is named `verified_on` (same required-ness, same meaning, same date-only rule, only the key name changes, to make room for the new `verified` described below). `verified`/`verified_on` note: it records when the fact was last confirmed true, which is not always today. A fact you re-checked against reality now is confirmed today, as is one the user is the authority for, a decision, preference, or intent they state directly. But a fact the user is recalling about external or system state is a source claim, not a re-check: date it to when that state was last checked or to the recollection's own date, not today. A claim copied from a dated source without re-checking carries that source's date. A fact taken from an undated record you cannot re-confirm (a memory file, an old conversation) carries the oldest date you can evidence, file timestamp, introducing commit, or the date it was said, never today; if no date can be evidenced, the fact is not yet verifiable, so find a datable source or leave it out. When the date is uncertain, round it down: an older `verified`/`verified_on` reads as "may be stale," today reads as "just confirmed." The date is the contract; a caveat in the concept body does not undo it, because the validator and tools read only the date. `timestamp` may be an ISO date (`YYYY-MM-DD`) in every supported format version. A `0.3` or `0.4` bundle may instead preserve a full datetime in the exact form `YYYY-MM-DDTHH:MM:SS[.fraction][Z|+HH:MM|-HH:MM]`; a space may replace `T`. Versions `0.1` and `0.2` remain date-only. `verified`/`verified_on` is always date-only. `source` quoting rule (hard): QUOTE every element of the `source` list. Source pointers routinely carry YAML-significant characters, a `#` (e.g. `"issue #445"`) starts a comment and corrupts the flow sequence, a colon-space `: ` splits a mapping, so a strict parser rejects an unquoted source. Always quote them: ```yaml source: ["README.md", "issue #445", "git log 9c2e510"] ``` The validator enforces this in both list styles. In flow style (`["a", b]`) an unquoted element carrying a significant character fails to parse and is reported as an error. In block style (`- a`) YAML would silently drop an inline `#` comment and pass, so the validator also scans the raw source text and rejects an unquoted element with a `#`. An element that is already quote-safe (a bare filename) is accepted either way, quote everything anyway so you never have to judge which is which. `tags` and `description` follow a lighter rule: quote an element only when it contains a YAML-significant character (a colon-space `: `, a leading `[ { # * & ! | > % @` or quote, or a trailing `:`); plain kebab tokens like `canonical` may stay unquoted. Quoting when unsure is always safe. Hard quoting is `source` only. Provenance lives in `source`, there is no separate citations section or references directory. ## Trust and provenance (upstream v0.2 vocabulary) Upstream Google OKF v0.2 (July 2026) added a second kind of frontmatter field: not one that describes a concept, but one a consumer uses to decide whether to trust it before reading the body. This fork adopts that vocabulary as optional additions, available on any concept regardless of type, in a bundle declaring `okf_version` `0.4`. None of it is required. A concept that uses none of these fields is exactly as valid as one authored against `0.1`. As with upstream, this fork records the raw signals and leaves scoring to the consumer, there is no computed trust score anywhere in a concept file or in the validator's output. A tool that wants "only surface human-reviewed metrics" derives that filter itself from the fields below. - **`generated`**, an optional mapping `{by, at}`: who or what produced the current content, and when it last meaningfully changed. `by` is a non-empty string identifying the producer (a model/agent name, or `human:<id>`); `at` is an ISO date or full ISO 8601 datetime. This sits alongside the required `timestamp`, not in place of it, `timestamp` is this fork's own authored/updated marker and stays required; `generated` is the richer, optional upstream form for describing production, and the two may describe the same event. - **`verified`** (only meaningful in a `0.4` bundle, where it is not the required key, see above), an optional YAML list of independent confirmations, each a mapping `{by, at}`: `by` a non-empty string (a `human:<id>` actor or a machine/agent identifier), `at` an ISO date. A consumer derives a trust tier from this list: no `verified` key is *unverified*; every entry from a machine/agent actor only is *machine-confirmed*; any entry from a `human:<id>` actor is *human-reviewed*. The validator checks only that the list is well-formed (non-empty entries, valid dates), deriving and filtering on a tier is a consumer's job, not this format's. - **`sources`** (plural, distinct from the required singular `source`), an optional YAML list of structured provenance objects, each with a required `id` (non-empty string, unique within the list, used to key an in-body footnote like `[^warehouse-schema]`) and `resource` (non-empty string: a URL or bundle-relative path), plus optional `title`, `author` (non-empty strings), `usage_count` (a non-negative integer), and `last_modified` (an ISO date). Where the required `source` is a flat list of quoted pointers, `sources` lets each pointer carry its own credibility signals and be cited per-claim in the body via an ordinary markdown footnote. The validator checks shape only, it does not cross-check that every `[^id]` footnote in the body has a matching `sources[].id`, or vice versa; treat that cross-reference as a human/reviewer responsibility for now. - **`status`**, an optional string, one of `draft`, `stable`, `deprecated`. Absent means `stable`. A `deprecated` concept is kept for history/reproducibility but should not be surfaced to new work. - **`stale_after`**, an optional ISO date `YYYY-MM-DD`. An absolute date, deliberately, not a relative TTL: staleness is then a plain date comparison with no reference to when the concept happened to be read. None of the above changes what the validator requires; it only makes the *absence* of these fields distinguishable from their presence where they matter to a consumer deciding whether to act on a concept. Absence means the signal was not supplied. A trust field that is explicitly present with a YAML null value is invalid; present fields must have the shape documented above. ## Type vocab Infrastructure and ops (fleet maps, system docs): `Machine`, `Network`, `Service`, `Session`, `Project`, `Repo`, `Credential`, `Path`, `Process`. Domain-neutral (newsrooms, research atlases, decision logs): `Concept`, `Decision`, `Event`, `Person`, `Org`, `Source`. `Reference` is the catch-all for a concept that is not one of the others. Index files carry no frontmatter, so there is no `Index` type. The set is closed: an unlisted type fails the build, which catches typos. To extend it, add the type here and in `scripts/validate.py`. `Attested Computation` joins this closed vocabulary only in a bundle declaring `okf_version` `0.4`; versions `0.1` through `0.3` reject it. ### `Attested Computation` (upstream v0.2) A concept of this type carries a sanctioned way to compute a value, and the means to check that the sanctioned computation actually ran, the answer to "was this number produced the way we said it must be," distinct from `verified` above (which confirms a *definition* still matches policy, not that any one run produced a correct value). It carries these keys in addition to the seven (or, at `0.4`, six-plus-`verified_on`) base required keys: | key | value | | --- | --- | | `runtime` | non-empty string naming the execution environment (e.g. `bigquery`) | | `parameters` | YAML list (possibly empty) of mappings, each `{name, type, required}`, the declared inputs a caller may fill; nothing else | | `executor` | mapping `{resource, receipt}`, `resource` points at the skill/tool that runs the computation, `receipt` a list naming what it returns (e.g. `[job_id, executed_sql, result]`) | | `attester` | mapping `{resource}`, points at the deterministic, non-LLM checker that compares a receipt against this concept's sanctioned computation | OKF records the computation and how to check it; this fork's validator checks only that these four keys are present and correctly shaped when `type: Attested Computation`. It never runs the computation, the executor, or the attester itself, that is a consumer's runtime responsibility, entirely outside this format and this validator. ## Links Relative markdown links. Every link to a file inside the bundle must resolve to a file that exists; a link that escapes the bundle root or dangles fails validation. The bundle is validated as one self-contained tree (see Federation for combining several). A link's case must match the file on disk. Case-insensitive filesystems on macOS and Windows can resolve `Concepts/Foo.md` to `concepts/foo.md`, while the same link dangles on Linux. The validator checks the spelling of each link component against directory listings before resolving can replace it with canonical casing. Its error gives the corrected relative link. Symlink and `..` components stay in filesystem traversal order, and the resolved destination must remain inside the bundle root. The `[[slug]]` wikilink form is not an OKF link, and the validator rejects it. It is the auto-memory cross-reference idiom and easy to reach for by habit, but a `[[slug]]` is never resolved or checked, so a dead reference would pass silently. Always link with `[text](relative/path.md)`. ## Federation (optional) Several bundles can be combined into one tree. Add a new root `index.md` that carries `okf_version` and links to each member, then place each bundle under a uniquely named subdirectory of that root. A member's own `index.md` is now a nested section index, so remove its `okf_version` frontmatter block entirely, a nested `index.md` carries no frontmatter at all. Write cross-bundle links as relative paths into the sibling directories. Validate by pointing the validator at the new root, so every link resolves and the single `okf_version` gate runs once at the top. A member's marker is stripped when it is nested, so it can no longer be validated on its own from inside the combined tree, validate the assembled root instead. (Per-node validation that keeps a marker in each member is planned but not yet built.) Most single-repo knowledge bases never need any of this. ## Security (hard) - No secret VALUES anywhere. A credential concept documents the key name, where it lives, and how it is retrieved, never the value itself. - The validator scans for private-key blocks, cloud-token shapes, and `secret=<value>` assignments and fails the build on a hit. If a pattern false-positives on legitimate text, narrow the pattern; do not delete the rule. - OKF makes no claim about whether your bundle is public or private. That is your decision, but a bundle that documents real infrastructure is usually internal. Decide deliberately before publishing. ## Tooling - `validate.py`, frontmatter conformance, date/list checks, link resolution, secret scan. Run it before every commit; it must exit 0. - `scaffold.py`, generate a conforming starter bundle.
-
-
templates
-
hooks
-
okf-anchor.py 3.6 KB
#!/usr/bin/env python3 """SessionStart hook: orient Claude on this OKF knowledge base. Prints the bundle's root index (the map of the knowledge base) so it lands in the session context, and work starts from the map instead of from memory. Claude Code injects a SessionStart hook's stdout into the session. Resolves the bundle relative to $CLAUDE_PROJECT_DIR (set by Claude Code), then this script's own location, then the cwd, so it works wherever the hook is launched from. No-ops silently if there is no bundle index. Never fails a session: a genuine error exits 0, but reports the reason on stderr so a broken bundle is not silently dropped. This is one cross-platform python3 script; only the launch command in .claude/settings.json differs per OS (python3 on macOS/Linux, python on Windows). """ import os import sys from pathlib import Path REL_CANDIDATES = ("bundle/index.md", "index.md") def find_index(): """Resolve the bundle root index.md. Order: $CLAUDE_PROJECT_DIR, then this script's install dir (<project>/.claude/ hooks/), then a walk up from the cwd. The first two pin the root directly. The cwd walk climbs upward looking for bundle/index.md, so a launch from inside bundle/<section>/ still resolves the root map, not the section's own index.md. A bare index.md at the cwd is the bundle-less last resort. """ bases = [] env_dir = os.environ.get("CLAUDE_PROJECT_DIR") if env_dir: bases.append(Path(env_dir)) bases.append(Path(__file__).resolve().parent.parent.parent) # <project>/.claude/hooks/ for base in bases: for rel in REL_CANDIDATES: p = base / rel if p.is_file(): return p cwd = Path.cwd().resolve() for ancestor in (cwd, *cwd.parents): p = ancestor / "bundle" / "index.md" if p.is_file(): return p p = cwd / "index.md" return p if p.is_file() else None def strip_frontmatter(text): """Drop a leading YAML frontmatter block (--- ... ---) if present.""" lines = text.splitlines() if lines and lines[0].strip() == "---": for i in range(1, len(lines)): if lines[i].strip() == "---": return "\n".join(lines[i + 1:]).strip() return text.strip() def main(): index = find_index() if index is None: return 0 # utf-8-sig strips a leading BOM; without it a BOM defeats the "---" frontmatter # check in strip_frontmatter and the raw YAML block would leak into the context. body = strip_frontmatter(index.read_text(encoding="utf-8-sig")) if not body: return 0 print( "OKF_ANCHOR: this project is an Open Knowledge Format (OKF) knowledge base. " "Orient on the index below before acting on it. It maps the concepts, their " "provenance, and how the bundle is organized. Drill into a section's index.md, " "then open only the concept you need; re-check the map when the task shifts area." ) print("--- begin OKF index ---") print(body) print("--- end OKF index ---") return 0 if __name__ == "__main__": try: sys.exit(main()) except Exception as exc: # noqa: BLE001 - last-resort guard around the whole hook # Stay fail-open (a broken bundle must not break the session), but not # silently: the orient gate tells Claude the index was injected, so a silent # anchor failure would make that claim false and hard to diagnose. sys.stderr.write( f"OKF anchor hook: could not inject the OKF index ({exc!r}); continuing " "without it. The orientation gate may reference an index that is not in " "context.\n" ) sys.exit(0) -
okf-orient.py 6.2 KB
#!/usr/bin/env python3 """PreToolUse hook: gate the first action on reading the OKF index. Blocks the first tool call of a session once (exit 2, reason on stderr), then unblocks for the rest of the session. The SessionStart hook (okf-anchor.py) has already placed the index in context; this is the speed bump that forces orientation before the first action. Fires only inside an OKF bundle (a bundle/index.md or index.md is present), so it is inert in any other project. Stateful per (session, project): a marker under the user's private cache dir is written on the first (blocking) call, so the immediate retry and everything after it pass. Never wedges a session and never silently disables the gate: on any error it surfaces the reason on stderr, then allows. Wired with no matcher in .claude/settings.json, so it sees the first tool call of any kind (Bash, Read, Edit, a tool from an MCP server, anything). This is one cross-platform python3 script; only the launch command differs per OS. """ import hashlib import json import os import sys from pathlib import Path REL_CANDIDATES = ("bundle/index.md", "index.md") def state_dir(): """Return the per-user directory that holds orientation markers. Deliberately NOT the world-shared system temp root: on a multi-user host another user could pre-create a marker there and bypass the gate. The default is the user's private cache dir (XDG_CACHE_HOME or ~/.cache), created 0700. Set OKF_ORIENT_STATE_DIR to override it (tests point it at a scratch path). """ override = os.environ.get("OKF_ORIENT_STATE_DIR") if override: base = Path(override) else: cache = os.environ.get("XDG_CACHE_HOME") or (Path.home() / ".cache") base = Path(cache) / "okf-orient" base.mkdir(parents=True, exist_ok=True, mode=0o700) return base def find_index(): """Resolve the bundle root index.md. Order: $CLAUDE_PROJECT_DIR, then this script's install dir (<project>/.claude/ hooks/), then a walk up from the cwd. The first two pin the root directly. The cwd walk climbs upward looking for bundle/index.md, so a launch from inside bundle/<section>/ still resolves the root map, not the section's own index.md. A bare index.md at the cwd is the bundle-less last resort. The marker key derives from this path's parent, so keying stays stable on the root regardless of cwd. """ bases = [] env_dir = os.environ.get("CLAUDE_PROJECT_DIR") if env_dir: bases.append(Path(env_dir)) bases.append(Path(__file__).resolve().parent.parent.parent) # <project>/.claude/hooks/ for base in bases: for rel in REL_CANDIDATES: p = base / rel if p.is_file(): return p cwd = Path.cwd().resolve() for ancestor in (cwd, *cwd.parents): p = ancestor / "bundle" / "index.md" if p.is_file(): return p p = cwd / "index.md" return p if p.is_file() else None def main(): raw = sys.stdin.read() data = json.loads(raw) if raw.strip() else {} index = find_index() if index is None: return 0 # not an OKF bundle: do not gate session_id = data.get("session_id") if not session_id: # Without a stable session id, "once per session" cannot be implemented: a # constant fallback key would make the gate fire once ever and skip every # later session in this project, and blocking without persisting would wedge # the session (the retry has no id either). So skip the gate, visibly. The # index was still injected at session start. sys.stderr.write( "OKF orientation gate: no session_id in the hook payload, so per-session " "state cannot be tracked; allowing without gating.\n" ) return 0 # Key the marker on (session, project) so a reused session id in another # project does not skip that project's gate. Hash keeps it filesystem-safe. key = hashlib.sha256( f"{session_id}|{index.resolve().parent}".encode("utf-8") ).hexdigest()[:16] # Record orientation before blocking, so the immediate retry and the rest of # the session pass. If the marker cannot be persisted (state dir unwritable, or # its parent already exists as a file), blocking would re-fire on every retry and # wedge the session -- so allow instead, but say so on stderr. This is a visible # fail-open, not the silent one the outer handler would give. try: marker = state_dir() / (key + ".oriented") if marker.exists(): return 0 # already oriented this session: allow marker.write_text("", encoding="utf-8") # set now so the immediate retry passes try: os.chmod(marker, 0o600) # owner-only; the state dir is already 0700 except OSError: pass # perms are defense-in-depth; the private dir is the real control except OSError as exc: sys.stderr.write( f"OKF orientation gate: could not record orientation state ({exc}); " "allowing this action so the session is not blocked. The bundle index was " "still placed in your context at session start.\n" ) return 0 sys.stderr.write( "OKF orientation gate (fires once per session): this is an Open Knowledge " "Format knowledge base and this is the first action of the session. The OKF " "index was placed in your context at session start (the bundle root index.md). " "Confirm you have read it -- briefly note what this bundle covers -- then retry " "your action. It will proceed; this gate does not fire again this session.\n" ) return 2 # block this one call if __name__ == "__main__": try: sys.exit(main()) except Exception as exc: # noqa: BLE001 - last-resort guard around the whole hook # Never wedge a session on a hook error, but never silently disable the gate # either: surface the failure, then allow the call. (SystemExit from a normal # return is not an Exception, so a real block still propagates.) sys.stderr.write( f"OKF orientation gate: unexpected hook error ({exc!r}); allowing this " "action so the session is not blocked.\n" ) sys.exit(0)
-
-
-
tests
-
test_okf_wiki.py 129.8 KB
"""Tests for the okf-wiki skill: the scaffolder and the validator. Each test runs the real CLI scripts in a temp directory, the same way a user would. Run: python3 -m pytest okf-wiki/tests/ -q """ import json import os import subprocess import sys from pathlib import Path import pytest SKILL = Path(__file__).resolve().parent.parent SCAFFOLD = SKILL / "scripts" / "scaffold.py" VALIDATE = SKILL / "scripts" / "validate.py" TEMPLATE_ANCHOR = SKILL / "templates" / "hooks" / "okf-anchor.py" TEMPLATE_ORIENT = SKILL / "templates" / "hooks" / "okf-orient.py" # Import the scaffolder module to unit-test its pure helpers directly. The CLI tests # below drive scaffold.py as a subprocess; the requirements name-sniffing contract is # fine-grained enough to pin here without a subprocess round-trip. sys.path.insert(0, str(SKILL / "scripts")) import scaffold as scaffold_mod # noqa: E402 the module under test; the local scaffold() helper below shadows the bare name # Load gh-wiki-bootstrap.py by path (its hyphenated name is not importable) so its # pure save-detection helper can be unit-tested without a browser. Its playwright # import lives inside main(), so importing the module top-level touches only stdlib. import importlib.util # noqa: E402 _boot_spec = importlib.util.spec_from_file_location( "gh_wiki_bootstrap", SKILL / "scripts" / "gh-wiki-bootstrap.py") gh_wiki_bootstrap = importlib.util.module_from_spec(_boot_spec) _boot_spec.loader.exec_module(gh_wiki_bootstrap) # The scaffolder evaluates a PEP 508 environment marker on a preserved requirements.txt's # PyYAML line only when `packaging` is importable; without it, it falls back to treating the # line as installable. Tests that assert an EXCLUDING marker warns therefore need packaging # present, so they guard on this. A matching or absent marker holds either way (the fallback # also selects the env), so those need no guard. HAS_PACKAGING = importlib.util.find_spec("packaging") is not None GOOD = """--- type: Process title: good description: a good concept source: ["README.md"] verified: 2026-06-23 timestamp: 2026-06-23 tags: ["x"] --- # good """ def scaffold(target, *args): r = subprocess.run([sys.executable, str(SCAFFOLD), str(target), *args], capture_output=True, text=True) return r.returncode, r.stdout + r.stderr def validate(bundle, *args): r = subprocess.run([sys.executable, str(VALIDATE), "--bundle", str(bundle), *args], capture_output=True, text=True) return r.returncode, r.stdout + r.stderr def write_concept(bundle, text, name="concepts/c.md"): p = bundle / name p.parent.mkdir(parents=True, exist_ok=True) p.write_text(text, encoding="utf-8") def run_hook(script, project_dir, stdin=None, cwd=None, extra_env=None): env = dict(os.environ, CLAUDE_PROJECT_DIR=str(project_dir)) if extra_env: env.update(extra_env) r = subprocess.run([sys.executable, str(script)], input=stdin, capture_output=True, text=True, env=env, cwd=cwd) return r.returncode, r.stdout, r.stderr def settings_of(target): return json.loads((target / ".claude" / "settings.json").read_text()) # --- scaffold --------------------------------------------------------------- def test_default_scaffold_validates(tmp_path): rc, out = scaffold(tmp_path / "kb") assert rc == 0, out assert "PASS" in out assert (tmp_path / "kb" / "bundle" / "index.md").exists() assert (tmp_path / "kb" / "SPEC.md").exists() assert (tmp_path / "kb" / "scripts" / "validate.py").exists() def test_multi_section_scaffold_validates(tmp_path): rc, out = scaffold(tmp_path / "kb", "--sections", "concepts,services,decisions") assert rc == 0, out for s in ("concepts", "services", "decisions"): assert (tmp_path / "kb" / "bundle" / s / "index.md").exists() def test_root_index_carries_okf_version(tmp_path): scaffold(tmp_path / "kb") root = (tmp_path / "kb" / "bundle" / "index.md").read_text() assert 'okf_version: "0.3"' in root def test_refuses_nonempty_dir_without_force(tmp_path): target = tmp_path / "kb" target.mkdir() (target / "keep.txt").write_text("x") rc, out = scaffold(target) assert rc == 1 and "not empty" in out def test_force_writes_into_nonempty_dir(tmp_path): target = tmp_path / "kb" target.mkdir() (target / "keep.txt").write_text("x") rc, out = scaffold(target, "--force") assert rc == 0, out def test_bad_date_arg_rejected(tmp_path): rc, out = scaffold(tmp_path / "kb", "--date", "2026-13-99") assert rc == 1 and "ISO" in out def test_section_slugifying_to_empty_is_rejected(tmp_path): # a punctuation-only section name slugifies to "" and would otherwise write # its index over bundle/index.md, clobbering the root. Must be refused. rc, out = scaffold(tmp_path / "kb", "--sections", "..") assert rc == 1 and "no alphanumeric" in out assert not (tmp_path / "kb" / "bundle" / "index.md").exists() def test_duplicate_sections_collapse(tmp_path): rc, out = scaffold(tmp_path / "kb", "--sections", "notes,notes") assert rc == 0, out assert (tmp_path / "kb" / "bundle" / "notes" / "index.md").exists() def test_scaffold_writes_requirements(tmp_path): # the validator depends on PyYAML; the scaffolded project must declare it. rc, out = scaffold(tmp_path / "kb") assert rc == 0, out assert "PyYAML" in (tmp_path / "kb" / "requirements.txt").read_text() def test_force_preserves_existing_content(tmp_path): # --force into a populated project must not clobber a user's own files with the # generic template. Scaffold once, edit content, re-scaffold --force: the edits # survive and the run reports what it preserved. target = tmp_path / "kb" scaffold(target) readme, root = target / "README.md", target / "bundle" / "index.md" readme.write_text("MY OWN README\n", encoding="utf-8") root.write_text("MY OWN INDEX\n", encoding="utf-8") rc, out = scaffold(target, "--force", "--no-validate") assert rc == 0, out assert readme.read_text() == "MY OWN README\n" assert root.read_text() == "MY OWN INDEX\n" assert "preserved" in out and "README.md" in out def test_force_preserve_skips_validation(tmp_path): # when --force preserves a non-OKF bundle/index.md, the scaffold is no longer # valid by construction; it must skip validation, not fail as "a bug in # scaffold.py" over intentionally preserved user content. target = tmp_path / "kb" scaffold(target) (target / "bundle" / "index.md").write_text("# my repo index\n", encoding="utf-8") rc, out = scaffold(target, "--force") assert rc == 0, out assert "skipping validation" in out.lower() assert "bug in scaffold.py" not in out # the printed validate command must cd into the target, not the caller's cwd assert f"cd {target}" in out def test_force_preserve_does_not_run_preserved_validator(tmp_path): # a preserved user scripts/validate.py must never be executed by the scaffolder. target = tmp_path / "kb" scaffold(target) sentinel = tmp_path / "ran" (target / "scripts" / "validate.py").write_text( f"import pathlib; pathlib.Path(r'{sentinel}').write_text('x')\n", encoding="utf-8") rc, out = scaffold(target, "--force") assert rc == 0, out assert not sentinel.exists(), "preserved validate.py must not be run" def test_missing_pyyaml_skips_validation(tmp_path): # validate.py needs PyYAML; if it is absent the scaffold must still succeed and # say so plainly, not mislabel the missing dependency as a bug in scaffold.py. # Simulate absence with -S (no site-packages); skip if yaml is still findable. probe = subprocess.run( [sys.executable, "-S", "-c", "import importlib.util, sys; " "sys.exit(0 if importlib.util.find_spec('yaml') is None else 3)"]) if probe.returncode != 0: pytest.skip("PyYAML is importable even under -S; cannot simulate its absence") target = tmp_path / "kb" r = subprocess.run([sys.executable, "-S", str(SCAFFOLD), str(target)], capture_output=True, text=True) out = r.stdout + r.stderr assert r.returncode == 0, out assert "PyYAML is not installed" in out assert "bug in scaffold.py" not in out assert (target / "bundle" / "index.md").exists() # the printed validate command must cd into the target, not the caller's cwd assert f"cd {target}" in out def test_force_preserve_warns_when_requirements_lacks_pyyaml(tmp_path): # A preserved requirements.txt is the user's own and stays untouched (#142). When it # omits PyYAML, the skip-validation message must name the exact missing dependency so # the user is not left to decode a later ModuleNotFoundError from validate.py. target = tmp_path / "kb" scaffold(target) (target / "requirements.txt").write_text("requests>=2\n", encoding="utf-8") rc, out = scaffold(target, "--force") assert rc == 0, out assert (target / "requirements.txt").read_text() == "requests>=2\n" # never edited assert "does not list PyYAML" in out assert "PyYAML>=5.1" in out def test_force_preserve_no_pyyaml_warning_when_declared(tmp_path): # If the preserved requirements.txt already declares PyYAML (any case or pin), the # targeted warning must not fire: validation is still skipped for the preserve, but we # do not nag a user who did the right thing. Lowercase + pin proves name normalization. target = tmp_path / "kb" scaffold(target) (target / "requirements.txt").write_text("pyyaml==6.0.1\n", encoding="utf-8") rc, out = scaffold(target, "--force") assert rc == 0, out assert "skipping validation" in out.lower() # the preserve still skips validation assert "does not list PyYAML" not in out def test_force_preserve_no_pyyaml_warning_with_include(tmp_path): # A "-r base.txt" include can declare PyYAML in a file the scaffolder does not read, so # an uncertain requirements.txt suppresses the targeted warning rather than making a # false "missing" claim. target = tmp_path / "kb" scaffold(target) (target / "requirements.txt").write_text("-r base.txt\nrequests\n", encoding="utf-8") rc, out = scaffold(target, "--force") assert rc == 0, out assert "skipping validation" in out.lower() assert "does not list PyYAML" not in out def test_force_preserve_warns_with_constraint_file_and_no_pyyaml(tmp_path): # A "-c constraints.txt" only pins versions of packages installed elsewhere; unlike an # include it cannot supply a missing package, so a preserved file that carries only a # constraint and no PyYAML declaration must still warn. Guards against treating -c as an # include and silently suppressing the note (pip: -c "Constrain versions", not install). target = tmp_path / "kb" scaffold(target) (target / "requirements.txt").write_text("-c constraints.txt\nrequests\n", encoding="utf-8") rc, out = scaffold(target, "--force") assert rc == 0, out assert "does not list PyYAML" in out assert "PyYAML>=5.1" in out @pytest.mark.skipif(not HAS_PACKAGING, reason="marker evaluation needs the packaging library") def test_force_preserve_warns_when_pyyaml_marker_excludes_env(tmp_path): # #185: a PyYAML line gated by a PEP 508 marker to another environment installs nothing # here, so the preserved file still lacks an importable PyYAML. The name sniffer alone # reads it as declared and stays silent; the marker must be evaluated so the note fires # when the declaration will not install for the interpreter that runs validate.py. target = tmp_path / "kb" scaffold(target) (target / "requirements.txt").write_text( 'PyYAML; sys_platform == "no-such-platform"\n', encoding="utf-8") rc, out = scaffold(target, "--force") assert rc == 0, out assert "does not list PyYAML" in out assert "PyYAML>=5.1" in out def test_force_preserve_no_warning_when_pyyaml_marker_matches(tmp_path): # the other side: a PyYAML line whose marker selects this interpreter installs normally, # so the note must stay silent. Guards against over-nagging on a valid environment gate. # Holds with or without packaging: the marker matches, and the no-packaging fallback also # treats the line as installable. target = tmp_path / "kb" scaffold(target) (target / "requirements.txt").write_text( 'PyYAML; python_version >= "3.0"\n', encoding="utf-8") rc, out = scaffold(target, "--force") assert rc == 0, out assert "skipping validation" in out.lower() assert "does not list PyYAML" not in out @pytest.mark.skipif(not HAS_PACKAGING, reason="marker evaluation needs the packaging library") def test_force_preserve_warns_when_pyyaml_hash_line_marker_excludes_env(tmp_path): # #185 (review): pip-compile --generate-hashes writes a requirement as a backslash- # continued block: the marker on the first physical line, indented `--hash` options on # following lines. Read physically, the first line keeps a trailing '\' that breaks marker # parsing, so an environment-excluded PyYAML read as installable and the note went silent. # Joining continuations first reconstructs the logical line, drops the hash options, and # the excluding marker fires the note. target = tmp_path / "kb" scaffold(target) (target / "requirements.txt").write_text( 'pyyaml==6.0.1 ; sys_platform == "no-such-platform" \\\n' ' --hash=sha256:aaaa \\\n' ' --hash=sha256:bbbb\n', encoding="utf-8") rc, out = scaffold(target, "--force") assert rc == 0, out assert "does not list PyYAML" in out def test_force_preserve_no_warning_when_pyyaml_hash_line_no_marker(tmp_path): # guard on the join: a hash-locked PyYAML with no marker reconstructs to an installable # line, so the note stays silent. Holds with or without packaging. target = tmp_path / "kb" scaffold(target) (target / "requirements.txt").write_text( 'pyyaml==6.0.1 \\\n --hash=sha256:aaaa \\\n --hash=sha256:bbbb\n', encoding="utf-8") rc, out = scaffold(target, "--force") assert rc == 0, out assert "does not list PyYAML" not in out def test_force_preserve_no_warning_when_comment_ends_in_backslash(tmp_path): # #185 (review): pip does not continue a full-line comment even when it ends in '\'. If the # join treated the comment as continuing, it would swallow the following PyYAML line into # the comment, hiding the declaration and firing a false 'missing PyYAML' note. The comment # must be emitted on its own so the real PyYAML line is read. Holds with or without packaging. target = tmp_path / "kb" scaffold(target) (target / "requirements.txt").write_text( "# a trailing note that ends in a backslash \\\n" "PyYAML==6.0.1\n", encoding="utf-8") rc, out = scaffold(target, "--force") assert rc == 0, out assert "does not list PyYAML" not in out def test_force_preserve_no_warning_when_comment_backslash_precedes_include(tmp_path): # #185 (review): same join bug, higher-impact variant. A comment ending in '\' just before # an `-r` include directive would swallow the directive, so the include (which may supply # PyYAML from a file we do not read) goes unseen and the note wrongly fires. The comment # must not continue, so the `-r` line is read and suppresses the note. target = tmp_path / "kb" scaffold(target) (target / "requirements.txt").write_text( "# via requirements.in \\\n" "-r base.txt\n", encoding="utf-8") rc, out = scaffold(target, "--force") assert rc == 0, out assert "does not list PyYAML" not in out @pytest.mark.parametrize("line,expected", [ ("PyYAML>=5.1", True), # no marker: installs here ('PyYAML; python_version >= "3.0"', True), # marker selects this interpreter ("PyYAML # just a note", True), # inline comment, no marker ("PyYAML; ", True), # empty marker after ';' ]) def test_requirement_marker_selects_env_true_cases(line, expected): # a missing, empty, or matching marker selects the current environment. These hold with # or without packaging, since the packaging-absent fallback also returns True, so no # skip guard is needed here (only the excluding case below depends on packaging). assert scaffold_mod._requirement_marker_selects_env(line) == expected @pytest.mark.skipif(not HAS_PACKAGING, reason="marker evaluation needs the packaging library") def test_requirement_marker_excludes_env(): # an excluding marker returns False only when packaging can evaluate it; the fallback # (packaging absent) returns True, so this case is guarded on packaging being present. assert scaffold_mod._requirement_marker_selects_env( 'PyYAML; sys_platform == "no-such-platform"') is False @pytest.mark.skipif(not HAS_PACKAGING, reason="marker evaluation needs the packaging library") def test_requirement_marker_hash_inside_quoted_marker_excludes(): # a '#' inside a quoted marker string is not a pip comment; stripping at the first '#' # would corrupt the marker. Parsing the full requirement keeps it intact, so the # excluding marker still evaluates (no implementation is named "cpython#not"). assert scaffold_mod._requirement_marker_selects_env( 'PyYAML; implementation_name == "cpython#not"') is False @pytest.mark.skipif(not HAS_PACKAGING, reason="marker evaluation needs the packaging library") def test_requirement_marker_semicolon_inside_url_excludes(): # a ';' inside a direct-reference URL is part of the URL, not the marker separator. # Splitting on the first ';' would misread the URL as the marker; parsing the full # requirement extracts the real trailing marker after ' ; ' and evaluates it. assert scaffold_mod._requirement_marker_selects_env( 'PyYAML @ https://example.com/pkg;param ; sys_platform == "no-such-platform"') is False @pytest.mark.skipif(not HAS_PACKAGING, reason="marker evaluation needs the packaging library") def test_requirement_marker_hashed_requirement_excluded(): # a hash-pinned line (pip-compile --generate-hashes) carries pip's per-requirement # `--hash` option, which packaging.requirements.Requirement rejects. The pip option must # be stripped before parsing, or the excluding marker is never seen and the missing-PyYAML # warning is wrongly suppressed. With the option dropped the marker evaluates and excludes. assert scaffold_mod._requirement_marker_selects_env( 'PyYAML==6.0.1; sys_platform == "no-such-platform" ' '--hash=sha256:aaaa --hash=sha256:bbbb') is False def test_requirement_marker_hashed_requirement_matches(): # a hash-pinned PyYAML with no marker still counts as installable: dropping the `--hash` # options leaves a bare, applicable requirement. Holds with or without packaging (the # packaging-absent fallback also returns True), so no skip guard is needed. assert scaffold_mod._requirement_marker_selects_env( 'PyYAML==6.0.1 --hash=sha256:aaaa') is True @pytest.mark.skipif(not HAS_PACKAGING, reason="marker evaluation needs the packaging library") def test_requirement_marker_whitespace_hash_inside_quoted_marker_excludes(): # #185 (review): a '#' preceded by whitespace *inside* a quoted marker value is part of the # marker, not a pip inline comment. Stripping the comment before parsing (the earlier # regex-first approach) corrupts the marker and the line reads as installable, wrongly # suppressing the note. Parsing the whole line first keeps the '#' intact, so the excluding # marker still evaluates (no implementation is named "cpython #not"). assert scaffold_mod._requirement_marker_selects_env( 'PyYAML; implementation_name == "cpython #not"') is False @pytest.mark.skipif(not HAS_PACKAGING, reason="marker evaluation needs the packaging library") def test_requirement_marker_trailing_comment_after_marker_excludes(): # guard on the parse-first change: a real pip inline comment after the marker must still be # stripped so the marker evaluates. The whole-line parse fails on the trailing comment, then # the comment is removed on retry and the excluding marker evaluates to False. assert scaffold_mod._requirement_marker_selects_env( 'PyYAML; sys_platform == "no-such-platform" # windows only') is False @pytest.mark.skipif(not HAS_PACKAGING, reason="marker evaluation needs the packaging library") def test_requirement_marker_unevaluable_lockfile_marker_does_not_crash(): # #185 (review): packaging 26 added lock-file-context marker variables. A line such as # `PyYAML; dependency_groups == "docs"` parses, but Marker.evaluate() raises KeyError in the # default metadata context because that variable is not defined there. Any evaluation failure # must be treated as unjudgeable and fall back to installable, not crash. Asserts the outcome # (True, no exception), which also holds on older packaging that rejects the marker at parse # time (InvalidRequirement, then True via the parse fallback). assert scaffold_mod._requirement_marker_selects_env( 'PyYAML; dependency_groups == "docs"') is True @pytest.mark.skipif(not HAS_PACKAGING, reason="marker evaluation needs the packaging library") def test_force_preserve_no_crash_on_unevaluable_marker(tmp_path): # #185 (review): a preserved requirements.txt whose PyYAML line carries a marker that parses # but cannot be evaluated in the default context (dependency_groups, a lock-file-only # variable) must not crash the --force run. The marker is unjudgeable, so PyYAML counts as # possibly installable and the note stays silent; the run exits 0. target = tmp_path / "kb" scaffold(target) (target / "requirements.txt").write_text( 'PyYAML; dependency_groups == "docs"\n', encoding="utf-8") rc, out = scaffold(target, "--force") assert rc == 0, out assert "does not list PyYAML" not in out @pytest.mark.parametrize("line,expected", [ ("PyYAML>=5.1", "pyyaml"), # version specifier stripped, lowercased ("pyyaml==6.0.1 # pinned", "pyyaml"), # pin and inline comment stripped ("PyYAML[extra]", "pyyaml"), # extras bracket stripped ("pyyaml @ https://example.com/p.whl", "pyyaml"), # direct reference: name kept ("ruamel.yaml", "ruamel-yaml"), # PEP 503 separator collapse (not PyYAML) ("# a comment", None), # comment names no package ("-r base.txt", None), # include option names no package ("https://example.com/p.whl", None), # bare URL names no distribution ("./local/pkg", None), # local path names no distribution ("", None), # blank line ]) def test_canonical_req_name(line, expected): # The pure name sniffer the preserved-requirements PyYAML check relies on: PEP 503 # normalization of the leading distribution name, and None for any line that names no # bare package (comment, option, include, URL, or path). Pinned here so a future edit # to the normalization cannot silently break PyYAML detection. assert scaffold_mod._canonical_req_name(line) == expected # --- validator (negative cases) --------------------------------------------- def test_validator_passes_on_good_concept(tmp_path): scaffold(tmp_path / "kb", "--no-validate") b = tmp_path / "kb" / "bundle" write_concept(b, GOOD) rc, out = validate(b) assert rc == 0, out def test_missing_required_key_fails(tmp_path): scaffold(tmp_path / "kb", "--no-validate") b = tmp_path / "kb" / "bundle" write_concept(b, GOOD.replace('tags: ["x"]\n', "")) rc, out = validate(b) assert rc == 1 and "tags" in out def test_bad_type_fails(tmp_path): scaffold(tmp_path / "kb", "--no-validate") b = tmp_path / "kb" / "bundle" write_concept(b, GOOD.replace("type: Process", "type: Wizard")) rc, out = validate(b) assert rc == 1 and "not in the spec vocab" in out def test_domain_neutral_type_validates(tmp_path): # the vocab is a superset: domain-neutral types (newsroom/research/decision-log) # validate alongside the infrastructure types. Closed-set typo rejection is still # covered by test_bad_type_fails above. scaffold(tmp_path / "kb", "--no-validate") b = tmp_path / "kb" / "bundle" for t in ("Concept", "Decision", "Event", "Person", "Org", "Source"): write_concept(b, GOOD.replace("type: Process", f"type: {t}"), name=f"concepts/{t}.md") rc, out = validate(b) assert rc == 0, out def test_invalid_date_shape_reports_cleanly(tmp_path): # date-shaped but invalid (month 13), PyYAML raises ValueError during parse, # which is not a YAMLError. Must report cleanly, not crash with a traceback. scaffold(tmp_path / "kb", "--no-validate") b = tmp_path / "kb" / "bundle" write_concept(b, GOOD.replace("verified: 2026-06-23", "verified: 2026-13-99")) rc, out = validate(b) assert rc == 1 and "parse error" in out assert "Traceback" not in out def test_source_must_be_list(tmp_path): scaffold(tmp_path / "kb", "--no-validate") b = tmp_path / "kb" / "bundle" write_concept(b, GOOD.replace('source: ["README.md"]', 'source: "README.md"')) rc, out = validate(b) assert rc == 1 and "must be a YAML list" in out def test_secret_value_fails(tmp_path): # build an AWS-key-shaped string from fragments so no literal secret-shaped # token lives in this test file. fake = "AKIA" + "IOSFODNN7" + "EXAMPLE" scaffold(tmp_path / "kb", "--no-validate") b = tmp_path / "kb" / "bundle" write_concept(b, GOOD.rstrip() + f"\nkey = {fake}\n") rc, out = validate(b) assert rc == 1 and "secret leak" in out # A labeled base64url secret value: URL-safe (- and _), so the base64-standard # generic pattern misses it. Built from fragments so no secret-shaped token lives # in this file. Its Shannon entropy is ~4.84 bits/char, well above the 4.0 floor. URLSAFE_SECRET = "Zk9" + "_qX2" + "-Lm7" + "vB4t" + "Nc1w" + "Rp8h" + "Ej6" + "-uYs" def test_urlsafe_secret_passes_without_entropy_scan(tmp_path): # Default behavior is unchanged: the opt-in scan is off, and the generic # base64 pattern deliberately does not match a hyphen/underscore value. scaffold(tmp_path / "kb", "--no-validate") b = tmp_path / "kb" / "bundle" write_concept(b, GOOD.rstrip() + f"\napi_key: {URLSAFE_SECRET}\n") rc, out = validate(b) assert rc == 0, out def test_entropy_scan_flags_urlsafe_secret(tmp_path): # With the opt-in flag, the same URL-safe value is caught by entropy. scaffold(tmp_path / "kb", "--no-validate") b = tmp_path / "kb" / "bundle" write_concept(b, GOOD.rstrip() + f"\napi_key: {URLSAFE_SECRET}\n") rc, out = validate(b, "--secret-entropy-scan") assert rc == 1 and "high-entropy assignment" in out def test_entropy_scan_keeps_okf_key_path(tmp_path): # The precision the earlier review round asked us to keep: a slash-delimited # OKF key path is not a secret even under the strict scan. Its own entropy # (~4.07) clears the floor, so this proves the structural `/` exclusion, not # just the threshold, is what protects documented key paths. scaffold(tmp_path / "kb", "--no-validate") b = tmp_path / "kb" / "bundle" write_concept(b, GOOD.rstrip() + "\nsecret: services/api/production-primary-key-path\n") rc, out = validate(b, "--secret-entropy-scan") assert rc == 0, out def test_entropy_scan_keeps_key_path_with_long_first_segment(tmp_path): # Regression for the #150 review: the `/` exclusion stops the match at the # separator, but a first path segment of >=24 url-safe chars was still captured # and entropy-checked on its own. Here `prd-usw2-mysql-rw-20260722-key-path` # (35 chars, entropy 4.01, above the floor) precedes the `/service` suffix, so # the old pattern flagged the segment as a value. The trailing lookahead now # requires a complete token, so a documented key path stays clean regardless of # how long its leading segment is. scaffold(tmp_path / "kb", "--no-validate") b = tmp_path / "kb" / "bundle" write_concept(b, GOOD.rstrip() + "\nsecret: prd-usw2-mysql-rw-20260722-key-path/service\n") rc, out = validate(b, "--secret-entropy-scan") assert rc == 0, out def test_entropy_scan_ignores_low_entropy_name(tmp_path): # A slashless but human-readable hyphenated value stays under the floor, so # the strict scan does not flag it. scaffold(tmp_path / "kb", "--no-validate") b = tmp_path / "kb" / "bundle" write_concept(b, GOOD.rstrip() + "\napi_key: prod-key-path-name-placeholder\n") rc, out = validate(b, "--secret-entropy-scan") assert rc == 0, out def test_entropy_scan_flags_secret_just_above_floor(tmp_path): # Recall is the flag's whole reason to exist, so pin it at the knife-edge: a # 24-char base64url value whose entropy is 4.054, just over the 4.0 floor, # must still flag. Below this the scan silently misses, the acknowledged # precision-for-recall tradeoff, so this marks where that boundary sits. marginal = "Ab-Cd" + "_Ef-Gh" + "_Ij-Kl" + "_Mn-Op1" scaffold(tmp_path / "kb", "--no-validate") b = tmp_path / "kb" / "bundle" write_concept(b, GOOD.rstrip() + f"\napi_key: {marginal}\n") rc, out = validate(b, "--secret-entropy-scan") assert rc == 1 and "high-entropy assignment" in out def test_dangling_link_fails(tmp_path): scaffold(tmp_path / "kb", "--no-validate") b = tmp_path / "kb" / "bundle" write_concept(b, GOOD.rstrip() + "\nSee [missing](nope.md).\n") rc, out = validate(b) assert rc == 1 and "dangling" in out def test_bundle_path_not_a_directory_fails_cleanly(tmp_path): # pointing --bundle at a file (not a dir) must report a clean failure, not # crash with a NotADirectoryError traceback from rglob/iterdir. f = tmp_path / "notabundle.md" f.write_text(GOOD, encoding="utf-8") rc, out = validate(f) assert rc == 1 and "not a directory" in out assert "Traceback" not in out def test_root_index_with_concept_frontmatter_fails(tmp_path): # the bundle-root index.md may carry only okf_version; arbitrary concept # metadata there must not slip through unchecked. scaffold(tmp_path / "kb", "--no-validate") b = tmp_path / "kb" / "bundle" (b / "index.md").write_text( '---\ntype: Credential\nnonsense: yes\n---\n# bad root\n', encoding="utf-8") rc, out = validate(b) assert rc == 1 and "only okf_version" in out def test_root_index_missing_okf_version_fails(tmp_path): scaffold(tmp_path / "kb", "--no-validate") b = tmp_path / "kb" / "bundle" (b / "index.md").write_text("# no frontmatter here\n", encoding="utf-8") rc, out = validate(b) assert rc == 1 and "okf_version" in out def test_root_index_unsupported_okf_version_fails(tmp_path): scaffold(tmp_path / "kb", "--no-validate") b = tmp_path / "kb" / "bundle" (b / "index.md").write_text('---\nokf_version: "0.9"\n---\n# root\n', encoding="utf-8") rc, out = validate(b) assert rc == 1 and "not supported" in out @pytest.mark.parametrize("version", ["0.1", "0.2"]) def test_root_index_legacy_version_validates(tmp_path, version): # Backward compatibility: older date-only bundles still validate under the # newer validator. scaffold(tmp_path / "kb", "--no-validate") root = tmp_path / "kb" / "bundle" / "index.md" root.write_text(root.read_text().replace( 'okf_version: "0.3"', f'okf_version: "{version}"'), encoding="utf-8") rc, out = validate(tmp_path / "kb" / "bundle") assert rc == 0, out def build_federated_tree(root, members=("nodeA", "nodeB"), strip_member_markers=True): """Assemble a combined tree the way SPEC.md "Federation" describes: a new root index.md carrying okf_version, each member under its own subdirectory.""" nav = "\n".join(f"- [{m}]({m}/index.md)" for m in members) (root).mkdir(parents=True, exist_ok=True) (root / "index.md").write_text( f'---\nokf_version: "0.1"\n---\n# Atlas\n\n{nav}\n', encoding="utf-8") for m in members: (root / m).mkdir(parents=True, exist_ok=True) marker = '---\nokf_version: "0.1"\n---\n' if not strip_member_markers else "" (root / m / "index.md").write_text( f"{marker}# {m}\n\n- [concept](concept.md)\n", encoding="utf-8") (root / m / "concept.md").write_text(GOOD, encoding="utf-8") def test_federated_tree_validates(tmp_path): # the documented strip-and-merge procedure must actually pass: one root marker, # members nested as marker-less section indexes. root = tmp_path / "atlas" build_federated_tree(root) rc, out = validate(root) assert rc == 0, out def test_nested_member_marker_fails(tmp_path): # the failure the SPEC warns about: leaving okf_version on a nested member index. root = tmp_path / "atlas" build_federated_tree(root, strip_member_markers=False) rc, out = validate(root) assert rc == 1 assert "reserved file should not carry frontmatter" in out def test_link_escaping_bundle_fails(tmp_path): # a link resolving above the bundle root is a hard failure even if such a file # exists on disk. (tmp_path / "outside.md").write_text("x", encoding="utf-8") scaffold(tmp_path / "kb", "--no-validate") b = tmp_path / "kb" / "bundle" write_concept(b, GOOD.rstrip() + "\nSee [x](../../../outside.md).\n") rc, out = validate(b) assert rc == 1 and "escapes bundle root" in out def test_link_with_title_resolves(tmp_path): # a CommonMark link with a title, [text](dest "title"), must not be flagged # dangling: the title is not part of the path. scaffold(tmp_path / "kb", "--no-validate") b = tmp_path / "kb" / "bundle" write_concept(b, GOOD.rstrip() + '\nSee [idx](index.md "the section index").\n') rc, out = validate(b) assert rc == 0, out def test_root_relative_link_rejected(tmp_path): # a '/'-prefixed link is absolute, which the spec forbids; it must fail even # if bundle/index.md happens to exist. scaffold(tmp_path / "kb", "--no-validate") b = tmp_path / "kb" / "bundle" write_concept(b, GOOD.rstrip() + "\nSee [r](/index.md).\n") rc, out = validate(b) assert rc == 1 and "root-relative link not allowed" in out def test_code_fence_link_examples_ignored(tmp_path): # a markdown link shown inside a fenced code block is illustrative, not a real # bundle link, and must not be reported as dangling. scaffold(tmp_path / "kb", "--no-validate") b = tmp_path / "kb" / "bundle" write_concept(b, GOOD.rstrip() + "\n\n```md\n[x](does-not-exist.md)\n```\n") rc, out = validate(b) assert rc == 0, out def test_inline_code_link_example_ignored(tmp_path): # a link inside an inline code span is also an example, not a real link. scaffold(tmp_path / "kb", "--no-validate") b = tmp_path / "kb" / "bundle" write_concept(b, GOOD.rstrip() + "\n\nUse `[x](nope.md)` syntax.\n") rc, out = validate(b) assert rc == 0, out def test_dangling_link_with_parens_caught(tmp_path): # a real (non-fenced) dangling link whose filename has balanced parens must # still be caught, the regex must not stop at the first ')'. scaffold(tmp_path / "kb", "--no-validate") b = tmp_path / "kb" / "bundle" write_concept(b, GOOD.rstrip() + "\nSee [x](missing(v2).md).\n") rc, out = validate(b) assert rc == 1 and "dangling" in out def test_four_backtick_fence_wraps_triple(tmp_path): # a 4-backtick fence enclosing a 3-backtick example stays closed until a # >=4-backtick fence; the inner example link must not leak out as dangling. scaffold(tmp_path / "kb", "--no-validate") b = tmp_path / "kb" / "bundle" write_concept(b, GOOD.rstrip() + "\n\n````md\n```\n[x](nope.md)\n```\n````\n") rc, out = validate(b) assert rc == 0, out def test_multi_backtick_inline_span_ignored(tmp_path): # a multi-backtick inline span (used when the code itself contains a backtick) # is still code, not a real link. scaffold(tmp_path / "kb", "--no-validate") b = tmp_path / "kb" / "bundle" write_concept(b, GOOD.rstrip() + "\n\nUse ``[x](nope.md)`` here.\n") rc, out = validate(b) assert rc == 0, out def test_nonmapping_frontmatter_fails(tmp_path): # syntactically valid YAML that is a list (not a mapping) must fail cleanly, # not crash the validator with an AttributeError traceback. scaffold(tmp_path / "kb", "--no-validate") b = tmp_path / "kb" / "bundle" write_concept(b, "---\n- a\n- b\n---\n# oops\n") rc, out = validate(b) assert rc == 1 and "must be a YAML mapping" in out assert "Traceback" not in out def test_md_directory_fails_cleanly(tmp_path): # rglob("*.md") also matches a directory named like "archive.md"; reading it # would raise IsADirectoryError. The validator must report a clean failure, not # crash with a traceback (it is copied into every scaffolded project). scaffold(tmp_path / "kb", "--no-validate") b = tmp_path / "kb" / "bundle" (b / "concepts" / "archive.md").mkdir(parents=True) rc, out = validate(b) assert rc == 1 and "must be a file, not a directory" in out assert "Traceback" not in out def test_nonscalar_type_reports_cleanly(tmp_path): # type as a list/dict (a plausible YAML typo) is unhashable; counting it or # testing membership would crash. Must report cleanly, not traceback. scaffold(tmp_path / "kb", "--no-validate") b = tmp_path / "kb" / "bundle" write_concept(b, GOOD.replace("type: Process", "type: [Process]")) rc, out = validate(b) assert rc == 1 and "'type' must be a string" in out assert "Traceback" not in out def test_missing_root_index_fails(tmp_path): # a bundle with concepts but no root index.md must fail: the okf_version gate # only runs when that file exists, so its absence would otherwise pass. scaffold(tmp_path / "kb", "--no-validate") b = tmp_path / "kb" / "bundle" write_concept(b, GOOD) (b / "index.md").unlink() rc, out = validate(b) assert rc == 1 and "bundle-root index is required" in out def test_bom_frontmatter_is_parsed(tmp_path): # a leading UTF-8 BOM (common from Windows editors) must not make valid # frontmatter read as missing; utf-8-sig strips it. scaffold(tmp_path / "kb", "--no-validate") b = tmp_path / "kb" / "bundle" write_concept(b, "" + GOOD) rc, out = validate(b) assert rc == 0, out def test_github_pat_secret_detected(tmp_path): # build a fine-grained PAT shape from fragments so no real-looking token lives # in this test file. The classic gh*_ pattern misses github_pat_. fake = "github_pat_" + "11ABCDE" + "FGHIJKLMNOPQRSTUVWXYZ" scaffold(tmp_path / "kb", "--no-validate") b = tmp_path / "kb" / "bundle" write_concept(b, GOOD.rstrip() + f"\ntoken = {fake}\n") rc, out = validate(b) assert rc == 1 and "secret leak" in out @pytest.mark.parametrize("label, token", [ ("Stripe secret key", "sk_" + "live_" + "A1b2C3d4E5f6G7h8I9j0K1L2"), ("Stripe organization key", "sk_" + "org_" + "A1b2C3d4E5f6G7h8I9j0K1L2"), ("Stripe webhook secret", "whsec_" + "A1b2C3d4E5f6G7h8I9j0K1L2M3n4O5p6"), ("GitLab token", "glpat-" + "A1b2C3d4E5f6G7h8I9j0"), # A valid 20-character GitLab token body can repeat characters and land just # below the generic 4.0-bit entropy floor (3.984 bits/character here). ("GitLab token", "glpat-" + "5lRDXNfPxOMFQmlFCcFZ"), ("GitLab token", "gloas-" + "A1b2C3d4E5f6G7h8I9j0"), ("GitLab token", "gldt-" + "A1b2C3d4E5f6G7h8I9j0"), ("GitLab token", "glrt-" + "A1b2C3d4E5f6G7h8I9j0"), ("GitLab token", "glrtr-" + "A1b2C3d4E5f6G7h8I9j0"), ("GitLab token", "glcbt-" + "abc_" + "A1b2C3d4E5f6G7h8I9j0"), ("GitLab token", "glptt-" + "A1b2C3d4E5f6G7h8I9j0"), ("GitLab token", "glft-" + "A1b2C3d4E5f6G7h8I9j0"), ("GitLab token", "glimt-" + "A1b2C3d4E5f6G7h8I9j0"), ("GitLab token", "glagent-" + "A1b2C3d4E5f6G7h8I9j0"), ("GitLab token", "glwt-" + "A1b2C3d4E5f6G7h8I9j0"), ("GitLab token", "glsoat-" + "A1b2C3d4E5f6G7h8I9j0"), ("GitLab token", "glffct-" + "A1b2C3d4E5f6G7h8I9j0"), ("npm token", "npm_" + "A1b2C3d4E5f6G7h8" + "I9j0K1L2M3n4O5p6Q7r8"), ("SendGrid API key", "SG." + "A" * 22 + "." + "B" * 43), ("Anthropic API key", "sk-" + "ant-" + "api03-" + "A1b2C3d4E5f6G7h8I9j0"), ("OpenAI project key", "sk-" + "proj-" + "A1b2C3d4E5f6G7h8I9j0"), ("OpenAI legacy key", "sk-" + "A1b2C3d4E5f6G7h8I9j0" + "K1L2M3n4O5p6Q7r8S9t0"), ]) def test_provider_token_secret_detected(tmp_path, label, token): # Prefix-anchored provider detectors (issue #150 move A). Each token is built # from fragments so no real-looking secret lives in this file. They run on the # default validate (no flag); the negative test below pins the path-documentation # precision boundary. scaffold(tmp_path / "kb", "--no-validate") b = tmp_path / "kb" / "bundle" write_concept(b, GOOD.rstrip() + f"\nkey = {token}\n") rc, out = validate(b) # Assert the specific detector, not just that some leak fired, so a token that # matched the wrong pattern would still be caught. assert rc == 1 and f"secret leak ({label})" in out def test_gitlab_session_cookie_secret_detected(tmp_path): # GitLab lists the session-cookie assignment itself alongside its fixed token # prefixes. Build the fake cookie from fragments so no real-looking value # lives in this test file. fake = "_gitlab_" + "session=" + "A1b2C3d4E5f6G7h8I9j0K1L2M3n4O5p6" scaffold(tmp_path / "kb", "--no-validate") b = tmp_path / "kb" / "bundle" write_concept(b, GOOD.rstrip() + f"\nCookie: {fake}\n") rc, out = validate(b) assert rc == 1 and "secret leak (GitLab session cookie)" in out def test_provider_prefixes_do_not_flag_prose(tmp_path): # The provider prefixes must not fire on documentation: a placeholder ellipsis, # an env-var name, words that merely contain an rk_/sk_ substring (the \b anchor # guards these), and provider-specific OKF key paths all stay clean on the # default validate. scaffold(tmp_path / "kb", "--no-validate") b = tmp_path / "kb" / "bundle" prose = ( "Set your sk_test_... key from the dashboard.\n" "The npm_config_registry env var points at the mirror.\n" "Use the work_live and mark_live feature flags.\n" "The pointer secret: svc/api/prod-key-path names a vault key, not a value.\n" "The pointer secret: openai/sk-proj-production-primary-key-path is a vault path.\n" "The pointer secret: anthropic/sk-ant-production-primary-key-path is a vault path.\n" "The pointer secret: gitlab/gldt-production-deploy-token-path is a vault path.\n" "The pointer secret: openai/sk-proj-A1b2C3d4E5f6G7h8I9j0/key-path is a vault path.\n" "GitLab documents the placeholder _gitlab_session=... for browser sessions.\n" ) write_concept(b, GOOD.rstrip() + "\n" + prose) rc, out = validate(b) assert rc == 0, out def test_link_to_existing_uppercase_md_fails(tmp_path): # the case the prior case-sensitive design worried about: a link to an existing # Foo.MD used to "resolve" as valid while discovery never scanned that file. Now # discovery finds Target.MD and rejects it as non-conforming, so a bundle that # contains it fails -- the file check and the link check stay in agreement. scaffold(tmp_path / "kb", "--no-validate") b = tmp_path / "kb" / "bundle" write_concept(b, GOOD, name="concepts/Target.MD") write_concept(b, GOOD.rstrip() + "\n\nSee [target](target.md).\n", name="concepts/c.md") rc, out = validate(b) assert rc == 1, out assert "rename Target.MD to target.md" in out assert "write Target.MD" not in out def test_nonconforming_extension_rename_uses_real_parent_case(tmp_path): scaffold(tmp_path / "kb", "--no-validate") b = tmp_path / "kb" / "bundle" write_concept(b, GOOD, name="concepts/Target.MD") write_concept( b, GOOD.rstrip() + "\n\nSee [target](../Concepts/target.md).\n", name="concepts/c.md", ) rc, out = validate(b) assert rc == 1, out assert "rename Target.MD to target.md" in out assert "rename Target.MD to ../Concepts/target.md" not in out def test_nonconforming_extension_rename_normalizes_link_extension(tmp_path): scaffold(tmp_path / "kb", "--no-validate") b = tmp_path / "kb" / "bundle" write_concept(b, GOOD, name="concepts/Target.MD") write_concept( b, GOOD.rstrip() + "\n\nSee [target](target.MD).\n", name="concepts/c.md", ) rc, out = validate(b) assert rc == 1, out assert "rename Target.MD to target.md" in out assert "rename Target.MD to target.MD" not in out def test_wrong_case_dangling_symlink_is_reported_as_dangling(tmp_path): scaffold(tmp_path / "kb", "--no-validate") b = tmp_path / "kb" / "bundle" try: (b / "concepts" / "Broken.md").symlink_to("missing.md") except OSError as exc: pytest.skip(f"symlink unavailable on this filesystem: {exc}") write_concept( b, GOOD.rstrip() + "\n\nSee [broken](broken.md).\n", name="concepts/c.md", ) rc, out = validate(b) assert rc == 1, out assert "dangling link -> broken.md" in out assert "write Broken.md" not in out def test_uppercase_scheme_link_not_flagged(tmp_path): # an external link with an uppercase scheme must be recognized as external and # skipped, not resolved as a local path (which falsely fails as escaping). scaffold(tmp_path / "kb", "--no-validate") b = tmp_path / "kb" / "bundle" write_concept(b, GOOD.rstrip() + "\nSee [s](HTTPS://example.com/readme.md).\n") rc, out = validate(b) assert rc == 0, out def test_documented_credential_path_not_flagged(tmp_path): # OKF credential concepts document key NAMES/paths, not values; a hyphenated # path after a secret-ish label must not read as a high-entropy leaked value. scaffold(tmp_path / "kb", "--no-validate") b = tmp_path / "kb" / "bundle" write_concept(b, GOOD.rstrip() + "\nsecret: service/api/prod-client-secret-path\n") rc, out = validate(b) assert rc == 0, out # --- session hooks: scaffold wiring ----------------------------------------- def test_default_scaffold_writes_hooks(tmp_path): scaffold(tmp_path / "kb", "--no-validate") c = tmp_path / "kb" / ".claude" assert (c / "settings.json").exists() assert (c / "hooks" / "okf-anchor.py").exists() assert (c / "hooks" / "okf-orient.py").exists() def test_no_hooks_skips_claude_dir(tmp_path): rc, out = scaffold(tmp_path / "kb", "--no-validate", "--no-hooks") assert rc == 0, out assert not (tmp_path / "kb" / ".claude").exists() assert "skipped" in out def test_scaffold_with_hooks_still_validates(tmp_path): # the .claude/ dir sits outside bundle/, so a default scaffold (hooks on) # must still validate clean. rc, out = scaffold(tmp_path / "kb") assert rc == 0 and "PASS" in out, out assert (tmp_path / "kb" / ".claude" / "settings.json").exists() def test_settings_json_structure(tmp_path): scaffold(tmp_path / "kb", "--no-validate") s = settings_of(tmp_path / "kb") assert "SessionStart" in s["hooks"] and "PreToolUse" in s["hooks"] start = s["hooks"]["SessionStart"][0]["hooks"][0] pre_group = s["hooks"]["PreToolUse"][0] assert "matcher" not in pre_group # no matcher => fires on the first tool call of any kind pre = pre_group["hooks"][0] # exec form: interpreter in `command`, script path as one `args` element (no shell # tokenization). The path must use the ${CLAUDE_PROJECT_DIR} placeholder, not a # bare cwd-relative path: the hook cwd is not guaranteed to be the project root. for hook, script in ((start, "okf-anchor.py"), (pre, "okf-orient.py")): assert hook["command"] in ("python3", "python"), hook assert hook["args"] == [f"${{CLAUDE_PROJECT_DIR}}/.claude/hooks/{script}"], hook def test_hooks_os_windows_uses_python(tmp_path): scaffold(tmp_path / "kb", "--no-validate", "--hooks-os", "windows") hook = settings_of(tmp_path / "kb")["hooks"]["SessionStart"][0]["hooks"][0] assert hook["command"] == "python" # the py launcher, not python3 def test_hooks_os_posix_uses_python3(tmp_path): scaffold(tmp_path / "kb", "--no-validate", "--hooks-os", "posix") hook = settings_of(tmp_path / "kb")["hooks"]["SessionStart"][0]["hooks"][0] assert hook["command"] == "python3" def test_readme_validate_command_matches_os(tmp_path): # the generated README's validate command must use the same interpreter as the # hooks; stock Windows has no python3, so a windows scaffold must say python. scaffold(tmp_path / "win", "--no-validate", "--hooks-os", "windows") win = (tmp_path / "win" / "README.md").read_text() assert "python scripts/validate.py" in win and "python3 scripts/validate.py" not in win scaffold(tmp_path / "nix", "--no-validate", "--hooks-os", "posix") assert "python3 scripts/validate.py" in (tmp_path / "nix" / "README.md").read_text() def test_force_merges_into_existing_settings(tmp_path): # scaffolding --force into a project that already has .claude/settings.json must # preserve the user's settings (permissions, unrelated events, their own # SessionStart hook) and add the OKF hooks, never overwrite the file wholesale. target = tmp_path / "kb" (target / ".claude").mkdir(parents=True) existing = { "permissions": {"allow": ["Bash(ls:*)"]}, "hooks": { "SessionStart": [{"hooks": [{"type": "command", "command": "echo hi"}]}], "Stop": [{"hooks": [{"type": "command", "command": "echo bye"}]}], }, } (target / ".claude" / "settings.json").write_text(json.dumps(existing), encoding="utf-8") (target / "keep.txt").write_text("x") rc, out = scaffold(target, "--force", "--no-validate") assert rc == 0, out s = settings_of(target) assert s["permissions"] == {"allow": ["Bash(ls:*)"]} # untouched assert s["hooks"]["Stop"] == [{"hooks": [{"type": "command", "command": "echo bye"}]}] start_cmds = [h.get("command") for g in s["hooks"]["SessionStart"] for h in g["hooks"]] assert "echo hi" in start_cmds # user's own hook preserved alongside ours anchors = [h for g in s["hooks"]["SessionStart"] for h in g["hooks"] if h.get("args") == ["${CLAUDE_PROJECT_DIR}/.claude/hooks/okf-anchor.py"]] assert len(anchors) == 1 assert "PreToolUse" in s["hooks"] def test_force_merge_is_idempotent(tmp_path): # running the scaffold twice must not duplicate the OKF hook entries. target = tmp_path / "kb" scaffold(target, "--no-validate") scaffold(target, "--force", "--no-validate") s = settings_of(target) anchors = [h for g in s["hooks"]["SessionStart"] for h in g["hooks"] if h.get("args") == ["${CLAUDE_PROJECT_DIR}/.claude/hooks/okf-anchor.py"]] orients = [h for g in s["hooks"]["PreToolUse"] for h in g["hooks"] if h.get("args") == ["${CLAUDE_PROJECT_DIR}/.claude/hooks/okf-orient.py"]] assert len(anchors) == 1 and len(orients) == 1 def test_force_backs_up_unparseable_settings(tmp_path): # a settings.json that is not valid JSON must be backed up, not silently # discarded, before the OKF settings are written in its place. target = tmp_path / "kb" (target / ".claude").mkdir(parents=True) (target / ".claude" / "settings.json").write_text("not json{", encoding="utf-8") (target / "keep.txt").write_text("x") rc, out = scaffold(target, "--force", "--no-validate") assert rc == 0, out assert (target / ".claude" / "settings.json.bak").read_text() == "not json{" assert "backed up" in out assert "SessionStart" in settings_of(target)["hooks"] def test_force_replaces_shellform_hook(tmp_path): # if an OKF hook is recorded in shell form (our exact path inside a `command` # string, e.g. hand-edited), --force must recognize it and replace it with the # exec-form entry, not leave both active. shlex-splitting the command exposes the # path token, which is then matched exactly against the paths we generate. target = tmp_path / "kb" (target / ".claude").mkdir(parents=True) legacy = {"hooks": { "SessionStart": [{"hooks": [{"type": "command", "command": 'python3 "${CLAUDE_PROJECT_DIR}/.claude/hooks/okf-anchor.py"'}]}], "PreToolUse": [{"hooks": [{"type": "command", "command": 'python3 "${CLAUDE_PROJECT_DIR}/.claude/hooks/okf-orient.py"'}]}], }} (target / ".claude" / "settings.json").write_text(json.dumps(legacy), encoding="utf-8") (target / "keep.txt").write_text("x") rc, out = scaffold(target, "--force", "--no-validate") assert rc == 0, out s = settings_of(target) anchors = [h for g in s["hooks"]["SessionStart"] for h in g["hooks"] if any("okf-anchor.py" in str(t) for t in [h.get("command")] + (h.get("args") or []))] assert len(anchors) == 1, s["hooks"]["SessionStart"] # legacy entry replaced, not duplicated assert anchors[0]["args"] == ["${CLAUDE_PROJECT_DIR}/.claude/hooks/okf-anchor.py"] # exec form def test_force_preserves_user_hook_sharing_okf_group(tmp_path): # a user may add their own hook into the same group as the OKF hook; replacing our # entry must strip only ours and keep theirs -- no whole-group drop (data loss). target = tmp_path / "kb" (target / ".claude").mkdir(parents=True) existing = {"hooks": { "SessionStart": [{"hooks": [ {"type": "command", "command": "python3", "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/okf-anchor.py"]}, {"type": "command", "command": "echo mine"}, ]}], }} (target / ".claude" / "settings.json").write_text(json.dumps(existing), encoding="utf-8") (target / "keep.txt").write_text("x") rc, out = scaffold(target, "--force", "--no-validate") assert rc == 0, out s = settings_of(target) cmds = [h.get("command") for g in s["hooks"]["SessionStart"] for h in g["hooks"]] assert "echo mine" in cmds # the user's hook in the shared group survives anchors = [h for g in s["hooks"]["SessionStart"] for h in g["hooks"] if any("okf-anchor.py" in str(t) for t in [h.get("command")] + (h.get("args") or []))] assert len(anchors) == 1, s["hooks"]["SessionStart"] # ours replaced once, not duplicated def test_force_keeps_lookalike_user_hook(tmp_path): # a user hook whose path merely starts with our script name (a .bak/-wrapper # variant) is NOT ours; a whole-token match means --force must not strip it. target = tmp_path / "kb" (target / ".claude").mkdir(parents=True) lookalike = "${CLAUDE_PROJECT_DIR}/.claude/hooks/okf-anchor.py.bak" existing = {"hooks": { "SessionStart": [{"hooks": [ {"type": "command", "command": "python3", "args": [lookalike]}, ]}], }} (target / ".claude" / "settings.json").write_text(json.dumps(existing), encoding="utf-8") (target / "keep.txt").write_text("x") rc, out = scaffold(target, "--force", "--no-validate") assert rc == 0, out s = settings_of(target) kept = [h for g in s["hooks"]["SessionStart"] for h in g["hooks"] if h.get("args") == [lookalike]] assert len(kept) == 1, s["hooks"]["SessionStart"] # lookalike untouched, not stripped as ours def test_force_keeps_same_named_hook_at_other_path(tmp_path): # a user hook that runs okf-anchor.py from a DIFFERENT location (a shared/global # hooks dir, not ${CLAUDE_PROJECT_DIR}) is not ours; exact-path matching means # --force must leave it in place. target = tmp_path / "kb" (target / ".claude").mkdir(parents=True) other = "/opt/shared/.claude/hooks/okf-anchor.py" existing = {"hooks": { "SessionStart": [{"hooks": [ {"type": "command", "command": "python3", "args": [other]}, ]}], }} (target / ".claude" / "settings.json").write_text(json.dumps(existing), encoding="utf-8") (target / "keep.txt").write_text("x") rc, out = scaffold(target, "--force", "--no-validate") assert rc == 0, out s = settings_of(target) kept = [h for g in s["hooks"]["SessionStart"] for h in g["hooks"] if h.get("args") == [other]] assert len(kept) == 1, s["hooks"]["SessionStart"] # foreign-path hook left untouched def test_force_backs_up_malformed_event_value(tmp_path): # a parseable settings.json whose event value is the wrong shape (not a list) must # be backed up rather than crashing the merge, preserving the original on disk. target = tmp_path / "kb" (target / ".claude").mkdir(parents=True) malformed = '{"hooks": {"SessionStart": 5}}' (target / ".claude" / "settings.json").write_text(malformed, encoding="utf-8") (target / "keep.txt").write_text("x") rc, out = scaffold(target, "--force", "--no-validate") assert rc == 0, out assert (target / ".claude" / "settings.json.bak").read_text() == malformed # original preserved assert "backed up" in out s = settings_of(target) anchors = [h for g in s["hooks"]["SessionStart"] for h in g["hooks"] if h.get("args") == ["${CLAUDE_PROJECT_DIR}/.claude/hooks/okf-anchor.py"]] assert len(anchors) == 1, s["hooks"]["SessionStart"] # fresh hooks written after backup def test_force_tolerates_malformed_hook_entry(tmp_path): # a list-shaped event holding a malformed hook entry (args not a list) must not # crash the merge; the odd entry is preserved (we can't claim it) and ours is added. target = tmp_path / "kb" (target / ".claude").mkdir(parents=True) existing = {"hooks": { "SessionStart": [{"hooks": [{"type": "command", "args": 5}]}], }} (target / ".claude" / "settings.json").write_text(json.dumps(existing), encoding="utf-8") (target / "keep.txt").write_text("x") rc, out = scaffold(target, "--force", "--no-validate") assert rc == 0, out s = settings_of(target) weird = [h for g in s["hooks"]["SessionStart"] for h in g["hooks"] if h.get("args") == 5] assert len(weird) == 1, s["hooks"]["SessionStart"] # malformed entry left in place anchors = [h for g in s["hooks"]["SessionStart"] for h in g["hooks"] if h.get("args") == ["${CLAUDE_PROJECT_DIR}/.claude/hooks/okf-anchor.py"]] assert len(anchors) == 1, s["hooks"]["SessionStart"] # our hook still added def test_force_preserves_unrelated_keys_when_hooks_malformed(tmp_path): # a parseable settings.json with unrelated live config (permissions) but a malformed # hooks subtree must keep the unrelated config in the LIVE file and just repair the # hooks; the original is copied to .bak. No whole-file reset, no lost permissions. target = tmp_path / "kb" (target / ".claude").mkdir(parents=True) existing = {"permissions": {"allow": ["Bash(ls)"]}, "hooks": {"SessionStart": 5}} (target / ".claude" / "settings.json").write_text(json.dumps(existing), encoding="utf-8") (target / "keep.txt").write_text("x") rc, out = scaffold(target, "--force", "--no-validate") assert rc == 0, out s = settings_of(target) assert s["permissions"] == {"allow":
-
-
requirements.txt 87 B
# The validator (scripts/validate.py) parses YAML frontmatter with PyYAML. PyYAML>=5.1 -
SKILL.md 18.4 KB
--- name: okf-wiki description: Builds an Open Knowledge Format (OKF) knowledge base from existing docs, notes, or a repo. Use to scaffold an OKF wiki. license: MIT metadata: author: jamditis version: "0.8.3" okf_spec: v1 --- # okf-wiki: scaffold an Open Knowledge Format knowledge base OKF (Open Knowledge Format) stores knowledge as small markdown files: one concept per file, each carrying its own provenance in YAML frontmatter, with directory `index.md` files for navigation and a validator that enforces the contract. It is built for knowledge bases that both people and agents read and edit, newsroom institutional memory, a research atlas, a team's decision log, an infrastructure map. This skill scaffolds a conforming OKF project and validates it. The format contract is in `spec/SPEC.md` (in this skill's directory), read it before changing structure. ## When to use - The user wants to start an OKF knowledge base, atlas, or wiki. - They want docs structured as one-concept-per-file with provenance, not prose pages. - They want to "initialize OKF" in a repo, optionally publishing into its GitHub wiki. ## Start here: scope the wiki with the user Before you scaffold anything, settle four things with the user. They shape what gets created and how it is published, and they are awkward to retrofit once concepts exist. Ask with `AskUserQuestion` rather than in prose, in two steps: the first three questions in one call, then the publish question as a follow-up call only if the audience came back public or both (it does not apply to an internal-only wiki, and its relevant options depend on that answer, so it cannot share the first batch). Infer the title from the repo or project and confirm it. Skip any question the user already answered in their request, do not re-ask what they have told you. 1. **Audience**, who reads this wiki? This answer sets the others: - **Internal (agents and teammates):** the orientation hooks earn their keep, so keep them on. The bundle may hold infrastructure detail, so it usually lives in a private repo. The in-repo `bundle/` is the source of truth. - **Public (people browsing):** readability and secret-scrubbing come first; the hooks matter less, since people read it and agents do not. Plan a published view (see Publish below). - **Both:** the in-repo `bundle/` is the source of truth with hooks on for agents, plus a published view for people. Default here when the user is unsure. 2. **Title and sections**, the knowledge-base title (infer it, then confirm) and the starting sections. Offer sections as a use-case preset, not a blank prompt: - Newsroom institutional memory: `people, orgs, sources, decisions, beats` - Research atlas: `concepts, sources, methods, findings` - Infrastructure or fleet map: `machines, services, networks, credentials, processes` - Decision log: `decisions, context, events` The chosen title and list feed `--title` and `--sections` below; the user can edit the list. 3. **Populate now or later**, author concepts now from existing material (a repo, docs, notes, or a URL: gather it and enter the authoring loop after scaffolding), or scaffold an empty tree the user fills in later. 4. **Publish target**, a follow-up `AskUserQuestion` call, made only after the audience comes back public or both (skip it entirely for an internal-only wiki): - **In-repo bundle only (default):** the validator and relative links work directly, with no extra surface to maintain. Right for most wikis. - **GitHub wiki:** an optional reading surface. Advanced and manual, see "Optional: publish into a GitHub wiki" below, bootstrapped with `scripts/gh-wiki-bootstrap.py`. - **GitHub Pages:** a browsable site rendered from the bundle. Not built yet, treat it as a goal and keep the in-repo bundle as the source of truth. Carry the answers into the scaffold command (the title and sections, plus `--no-hooks` if the user opts out of the hooks for a public-only wiki) and into the populate step. The audience answer is also the visibility decision the "Before finishing" section asks you to make deliberately, you are making it here, up front, where it can steer the rest of the setup. ## What gets created `scripts/scaffold.py` writes a project that passes its own validator by construction: ``` <target>/ SPEC.md the OKF format contract README.md how to use and validate the bundle scripts/validate.py the validator .claude/ Claude Code adapter: session-orientation hooks settings.json registers the hooks (Claude Code approves them once) hooks/okf-anchor.py SessionStart: load the index into context hooks/okf-orient.py PreToolUse: gate the first action on orientation bundle/ the OKF bundle (the validated tree) index.md carries okf_version: "0.3" by default; "0.4" with --trust-signals <section>/ index.md example-concept.md a starter concept with full frontmatter ``` Docs and tooling sit at the project root; only `bundle/` is validated. Keep them separate, the validator treats every non-reserved `.md` inside the bundle as a concept that needs frontmatter, so a stray `SPEC.md` inside `bundle/` would fail. The `.claude/` hooks sit outside `bundle/`, so they never trip the concept checks. ## How to run it `${CLAUDE_SKILL_DIR}` below is this skill's own directory (the folder holding this `SKILL.md`). Claude Code substitutes it with the real absolute path before you run the command, so it works regardless of the current directory. On Windows, use `python` instead of `python3` (stock Windows has no `python3`). The `--title` and `--sections` come from the onboarding answers above, and `--no-hooks` only if the user opted out. Scaffold into a new directory; it validates automatically at the end: ```bash python3 "${CLAUDE_SKILL_DIR}/scripts/scaffold.py" ./my-knowledge-base \ --title "Team knowledge base" \ --sections concepts,services,decisions ``` Default section is `concepts`. Use `--force` to write into a non-empty directory, `--no-validate` to skip the validation run, and `--date YYYY-MM-DD` to set the sample frontmatter date. The session hooks are written by default; `--no-hooks` skips them and `--hooks-os posix|windows` overrides the auto-detected launch command (see below). Validate any time, from the scaffolded project root (use `python` on Windows): ```bash python3 scripts/validate.py --bundle bundle # must exit 0 ``` ## Populate the bundle: author concepts from existing material Scaffolding leaves an empty tree with one placeholder concept. The usual next request, "here are my docs / plans / notes / repo, build the wiki", has no importer script, and can't have one: deciding what counts as a single concept, writing its one-line description, choosing its `type`, and pointing `source` at real provenance is judgment work, not a mechanical transform. So you (Claude) author the concepts directly, in this loop: 1. **Gather the source.** Read what the user pointed you at, a file, a folder, a repo, or a URL (fetch a URL first). Skim the whole thing before writing anything, so you can see the natural concept boundaries. 2. **Decide concept boundaries.** One file is one concept: one thing a reader would look up on its own (a service, a decision, a path, a person, an event). Split a doc that covers five things into five concepts; merge fragments that only mean something together into one. A heading is a hint, not a rule, do not blindly map one `##` to one file. 3. **Draft each concept** at `bundle/<section>/<slug>.md` with the full frontmatter. Read the bundle-root `index.md` before writing so the verification key matches its declared format: use `verified` for `okf_version` `0.1` through `0.3`; use `verified_on` for `okf_version` `0.4`. Emit that exact key with `type, title, description, source, timestamp, tags`: - `type` from the vocab. Infrastructure: Machine, Network, Service, Session, Project, Repo, Credential, Path, Process. Domain-neutral: Concept, Decision, Event, Person, Org, Source. Plus Reference (the catch-all). The set is closed; an unlisted type fails. - `description` is one line. `source`, quote every element, points at where the fact actually came from (the origin file path, URL, command, or event), not at this skill. - Set `timestamp` to today. `verified`/`verified_on` is the date the fact was last confirmed true, set it by how you came to know it, not reflexively to today: - You re-checked it against reality now, or the user is the authority for it (a decision, preference, or intent they state in this session): today. - The user is recalling external or system state (a spec, a path, a config): their memory is a source claim, not a re-check, so date it to when that state was last checked or to the recollection's own date, not today just because it came up now. - It was copied from a dated source without re-checking: the date it was last known true (the source's own date), not today. - It came from an undated record you cannot re-confirm (a memory file, an old conversation): the oldest date you can evidence, file timestamp, introducing commit, or the date it was said, never today. If you cannot evidence any date at all, it is not yet a verifiable fact; find a datable source or leave the concept out. When the date is uncertain, round it down: an older `verified` correctly reads as "may be stale, re-check," while today reads as "just confirmed." The frontmatter date is the contract; a caveat in the body does not undo an overstated value, because the validator and tools read only the date. - Strip secret values as you go: a credential concept names the key and its retrieval path, never the value. The validator fails the build on a leaked secret. 4. **Place and link.** Put each concept in the right section (create sections as needed), add a bullet for it to that section's `index.md`, and cross-link related concepts with relative `[text](path.md)` links, not `[[slug]]` wikilinks. `[[slug]]` is the auto-memory idiom; the OKF validator rejects it and never resolves it, so a typo'd or deleted reference passes silently. When you create a new section, also link it from the bundle-root `index.md`, that root is the navigation map the session anchor loads, so a section missing from it is invisible to orientation even though validation still passes. 5. **Clear the placeholder.** If you scaffolded fresh, delete the starter `example-concept.md` (and its bullet in the section `index.md`) once real concepts exist, otherwise the sample ships in the finished wiki and still passes validation. 6. **Validate in a loop.** Run `python3 scripts/validate.py --bundle bundle`, fix what it reports, repeat until it exits 0. Unquoted `source` elements and missing frontmatter keys are the common failures. Author in batches and validate between them rather than writing fifty files and debugging the lot. ### When the source is already OKF If the user points you at an existing OKF bundle (e.g. an upstream example: an `index.md` carrying `okf_version` plus concept files with frontmatter), you are adopting it, not importing it. Copy or clone the tree in, point the validator at the new root, and fix any links that broke in the move. To keep it as its own area beside other content, give it a uniquely named top directory, then create one combined-root `index.md` that carries `okf_version` and strip the frontmatter from each adopted bundle's own root `index.md`, turning it into a normal section index (the validator allows `okf_version` on the one combined root only; a nested `index.md` that still carries it fails validation). Write cross-links as relative paths and validate the combined root. Re-authoring an already-conforming bundle into your own concepts is wasted work; only reshape it if that is the actual goal. ## The format, briefly Full contract in `spec/SPEC.md`. This spec is a strict fork of Google's upstream OKF: it requires all seven frontmatter keys, uses a `source` list in place of upstream's `resource` and `# Citations`, adds a verification-date key, closes the type vocab, and enforces link resolution. `spec/SPEC.md` ("Relationship to upstream OKF") lists every difference. The load-bearing rules: - **Required frontmatter** on every concept: `type, title, description, source`, the version-specific verification key described above, `timestamp, tags`. `type` is one of: Machine, Network, Service, Session, Project, Repo, Credential, Path, Process (infrastructure); Concept, Decision, Event, Person, Org, Source (domain-neutral); or Reference (catch-all). - **Quote every `source` element**, source pointers carry `#` and `: ` which break YAML if unquoted. `source: ["README.md", "issue #445"]`. - **`verified`/`verified_on`** is the date the fact was last confirmed true, a re-check against reality, or the user stating a fact they are the authority for (a decision, a preference); a fact they merely recall about external state is a source claim, not a re-check. **`timestamp`** is when the concept was authored/updated. The verification date is ISO `YYYY-MM-DD`; `timestamp` may also be a full ISO 8601 datetime in `0.3` and `0.4`. See the authoring loop above for the full date rules. - **No secret values, ever.** A credential concept documents the key name and retrieval path, never the value. The validator fails the build on a leaked secret. - **`index.md` and `log.md` are reserved**, no frontmatter (except the bundle-root `index.md`, which carries `okf_version` only). ### Optional: upstream v0.2 trust/provenance signals Upstream Google OKF v0.2 (July 2026) added an optional vocabulary for a consumer to judge a concept before reading it: `generated` (who/what produced it), `verified` (a list of independent confirmations, not this fork's own single-date field), `sources` (structured, per-pointer credibility signals), `status` (draft/stable/deprecated), `stale_after` (an absolute expiry date), and an `Attested Computation` type for a sanctioned, checkable computation. None of it is required, and a bundle that adopts none of it is unaffected. Scaffold a project with these enabled, `scaffold.py <target> --trust-signals`, and the bundle declares `okf_version: "0.4"`, with `verified` renamed to `verified_on` in the required set (freeing `verified` for the new shape; see `spec/SPEC.md`'s "Trust and provenance" section for the full field contract and the reasoning behind the rename). `Attested Computation` is likewise a `0.4`-only type. Without the flag, scaffolding is unchanged from before this vocabulary existed. ## Session hooks A scaffolded project ships a `.claude/` with two hooks so any Claude session opened in it starts from the bundle, not from memory: - **`okf-anchor.py`** (SessionStart) prints the bundle's root index into the session context. - **`okf-orient.py`** (PreToolUse, no matcher) blocks the first action of the session once, until Claude confirms it read the index, then unblocks for the rest of the session. It is inert outside an OKF bundle and fails open on any error, so it never wedges a session. Both are one cross-platform python3 script. The scripts are identical on every OS; only the interpreter in `.claude/settings.json` changes: `python3` on macOS/Linux, `python` on Windows. `scaffold.py` auto-detects the OS; `--hooks-os posix|windows` forces it. Claude Code treats a checked-in `.claude/settings.json` as untrusted, so the first time the project is opened it asks the user to approve the hooks; they run automatically after that. To turn them off, scaffold with `--no-hooks`, or delete `.claude/` (or set `disableAllHooks`) in an existing project. ### Client boundary The portable OKF surface is `SPEC.md`, `requirements.txt`, `scripts/validate.py`, and the `bundle/` tree. The generated `README.md` documents both that shared surface and any enabled client adapter. The three generated `.claude/` files are a Claude Code adapter, not part of the OKF format and not shared Codex behavior. Codex does not read them as project configuration, and this skill must not claim that their `SessionStart` or `PreToolUse` lifecycle runs there. The general onboarding route above still names Claude Code's `AskUserQuestion` and `${CLAUDE_SKILL_DIR}` surfaces. The recorded Codex pilot pre-set every onboarding choice and used an explicit project-relative installed path; it does not establish that the unadapted general route is portable. For a mixed Claude Code and Codex project, keep `.claude/` so Claude Code can request trust and use the hooks; Codex leaves it inert. For a Codex-only project, pass `--no-hooks` while scaffolding or delete `.claude/` afterward. Either choice leaves the portable bundle and validator unchanged. ## Optional: publish into a GitHub wiki OKF lives best as in-repo files (the validator and relative links work directly). A repo's GitHub wiki is an optional reading surface, and wiring it up is an advanced, manual step, most users should skip it and keep the bundle in-repo. A wiki with zero pages has no git repo to push to and no API, so the very first page must be created through the web UI. `scripts/gh-wiki-bootstrap.py` automates that one step, but it drives a real logged-in browser, so it needs two things you provide yourself (a GitHub PAT does not work, wiki pages are a web-UI-only surface): - **Playwright with Chromium installed:** `pip install playwright && playwright install chromium`. - **A saved GitHub web session:** a Playwright `storageState` JSON, captured from a browser where you have already logged into GitHub. The script reuses that session; it does not log in for you. Pass its path with `--state` (default: `~/.cache/gh_state.json`). ```bash python3 "${CLAUDE_SKILL_DIR}/scripts/gh-wiki-bootstrap.py" owner/repo --state path/to/gh_state.json # then: git clone https://github.com/owner/repo.wiki.git and push your pages ``` Note the impedance: GitHub wikis are flatter than an OKF tree and use `[[WikiLinks]]`, so OKF's nested directories and relative links need adapting for the wiki surface. Treat the wiki as a published view, not the source of truth. (v0.1 ships the bootstrap step; an automatic bundle-to-wiki sync is not built yet.) ## Before finishing - Run the validator and confirm it exits 0. - Confirm the visibility you set during onboarding still fits what got authored: a bundle that ended up documenting real infrastructure is usually internal. OKF takes no position; you must.
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.