From 6874cb497c0c6cf1da81df04b3088b5ee57d278b Mon Sep 17 00:00:00 2001 From: Gerlando Piro Date: Wed, 8 Jul 2026 11:17:17 +0200 Subject: [PATCH 1/4] feat: add deterministic wiki-format parity gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Format parity with OpenWiki was previously enforced only by prose doctrine in references/wiki-format.md, with no automated check — unlike algorithm parity, which tests/noop.test.ts verifies case for case. check-format.sh closes that gap: it validates an openwiki/ directory against the format contract (entrypoint filename, H1-first pages, resolving relative links, ## Source map / Git evidence: shape, init page ceiling) and emits {ok, pages, problems, warnings} JSON, following the same script-emits-JSON convention as the rest of scripts/. tests/check-format.test.ts exercises it against a real temp git repo per the project's co-located-behavioral-test convention. Co-Authored-By: Claude Fable 5 --- scripts/check-format.sh | 242 ++++++++++++++++++++++++++++++ tests/check-format.test.ts | 291 +++++++++++++++++++++++++++++++++++++ 2 files changed, 533 insertions(+) create mode 100644 scripts/check-format.sh create mode 100644 tests/check-format.test.ts diff --git a/scripts/check-format.sh b/scripts/check-format.sh new file mode 100644 index 0000000..e1023f9 --- /dev/null +++ b/scripts/check-format.sh @@ -0,0 +1,242 @@ +#!/usr/bin/env bash +# check-format.sh — validates that an openwiki/ directory conforms to the +# wiki-format.md PARITY CONTRACT. This is a read-only validation gate, not a +# feature: it enforces the exact format that references/wiki-format.md already +# mandates (entrypoint filename, H1-first pages, resolving relative links, the +# `## Source map` / `Git evidence:` shape, the init page ceiling). The init and +# update skills run it before writing state so a wiki that has drifted from the +# contract never gets committed. +# +# Emits a single JSON object on stdout: +# {"ok":bool,"pages":int,"problems":[...strings],"warnings":[...strings]} +# `ok` is true iff `problems` is empty. `pages` counts .md files under openwiki/ +# (excluding .last-update.json — it is json, not md — and _plan.md if present). +# Exit codes: 0 = evaluated, 2 = precondition missing (no openwiki/ dir). +# +# Dependency-free: bash 3.2 + coreutils. No associative arrays, no ${var,,}. +set -euo pipefail + +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=scripts/lib/json.sh +. "$SCRIPT_DIR/lib/json.sh" + +DIR="." +while [ $# -gt 0 ]; do + case "$1" in + --dir) DIR=$2; shift 2 ;; + *) printf 'check-format.sh: unknown argument: %s\n' "$1" >&2; exit 2 ;; + esac +done + +WIKI="$DIR/openwiki" + +if [ ! -d "$WIKI" ]; then + printf 'check-format.sh: %s/openwiki does not exist\n' "$DIR" >&2 + exit 2 +fi + +# Accumulators. Newline-delimited so bash 3.2 can iterate them without arrays. +PROBLEMS="" +WARNINGS="" +add_problem() { PROBLEMS="${PROBLEMS}$1"$'\n'; } +add_warning() { WARNINGS="${WARNINGS}$1"$'\n'; } + +# --- Enumerate documentation pages (.md), excluding _plan.md. ----------------- +# .last-update.json is json, not md, so a plain *.md filter already excludes it. +# Sorted for deterministic problem ordering. Relative paths inside openwiki/. +PAGES=$( + cd "$WIKI" || exit 1 + find . -type f -name '*.md' | LC_ALL=C sort | while IFS= read -r f; do + rel=${f#./} + [ "$rel" = "_plan.md" ] && continue + printf '%s\n' "$rel" + done +) + +pages_count=0 +if [ -n "$PAGES" ]; then + pages_count=$(printf '%s\n' "$PAGES" | grep -c '' || true) +fi + +# --- Problem 1: quickstart.md must exist. -------------------------------------- +if [ ! -f "$WIKI/quickstart.md" ]; then + add_problem "openwiki/quickstart.md is missing (the wiki entrypoint)" +fi + +# --- Problem 5: the temporary plan must have been removed by init. ------------- +if [ -f "$WIKI/_plan.md" ]; then + add_problem "openwiki/_plan.md is still present (init must delete the plan)" +fi + +# --- Per-page checks. ---------------------------------------------------------- +# Iterate the enumerated pages. Each check appends repo-relative page context so +# a listed problem points the skill at the file to fix. +if [ -n "$PAGES" ]; then + while IFS= read -r rel; do + [ -z "$rel" ] && continue + file="$WIKI/$rel" + + # Problem 2: first line must be a level-1 ATX heading ("# ..."). A leading + # "---" (YAML frontmatter) or any non-"# " first line is a parity violation. + first_line=$(awk 'NR==1{print; exit}' "$file") + case "$first_line" in + "# "*) : ;; # ok — level-1 heading + "---") + add_problem "openwiki/$rel starts with '---' (YAML frontmatter is forbidden; first line must be a '# ' heading)" + ;; + *) + add_problem "openwiki/$rel first line is not a '# ' level-1 heading" + ;; + esac + + # Problem 3: relative Markdown links to same-wiki targets must resolve. + # Extract link targets of the form ](target). Ignore external/anchor links. + # A target that is a wiki-relative path (./x, x.md, sub/x.md) resolves + # against openwiki/; a repo-relative target (../src/...) resolves against + # the repo dir. Only flag a missing target. + targets=$(grep -oE '\]\([^)]+\)' "$file" 2>/dev/null | sed -e 's/^](//' -e 's/)$//' || true) + if [ -n "$targets" ]; then + while IFS= read -r target; do + [ -z "$target" ] && continue + # Strip a trailing #anchor and any surrounding whitespace. + target=${target%%#*} + target=${target%"${target##*[![:space:]]}"} + target=${target#"${target%%[![:space:]]*}"} + [ -z "$target" ] && continue + case "$target" in + http://*|https://*|mailto:*|//*) continue ;; # external + /*) continue ;; # absolute path — out of scope + *:*) continue ;; # other scheme (e.g. ftp:) + esac + # Only consider Markdown-ish same-wiki targets: those ending in .md or + # beginning with ./ or ../ . Anything else (e.g. an image, a bare + # fragment already stripped) we leave alone to stay conservative. + case "$target" in + *.md|./*|../*) : ;; + *) continue ;; + esac + # Resolve against the page's own directory using real filesystem + # semantics, so `./x.md` and `../topic/y.md` (same-wiki) resolve inside + # openwiki/, while a link that escapes with enough `../` to reach repo + # files resolves against the repo. `[ -e ]` follows `..` for us; a + # missing target (broken same-wiki link, or a repo file that moved) is + # the only thing we flag. + page_dir=$(dirname "$file") + resolved="$page_dir/$target" + if [ ! -e "$resolved" ]; then + add_problem "openwiki/$rel has a broken relative link: $target" + fi + done < 0) { cnt++; last[cnt] = arr[i]; } + for (i = 1; i <= cnt; i++) { + line = last[i] + if (line ~ /Git evidence:/) { + # Must be the LAST bullet. + if (i != cnt) { print "notlast"; } + # Must match exactly: "- Git evidence: commits `<7hex>`" then zero or + # more ", `<7hex>`". Backticks + 7-char lowercase hex required. + if (line !~ /^-[[:space:]]Git evidence: commits `[0-9a-f]{7}`([[:space:]]*,[[:space:]]*`[0-9a-f]{7}`)*[[:space:]]*$/) { + print "malformed"; + } + } + } + } + ' "$file" 2>/dev/null || true) + case "$smverdict" in + *malformed*) + add_problem "openwiki/$rel has a malformed 'Git evidence:' bullet (must be \`- Git evidence: commits \`<7-hex>\`\` with 7-char backticked hashes)" + ;; + esac + case "$smverdict" in + *notlast*) + add_problem "openwiki/$rel has a 'Git evidence:' bullet that is not the last bullet of its source map" + ;; + esac + + # Warning: a non-quickstart .md page sitting at the openwiki/ root instead of + # a topic subdirectory. + case "$rel" in + */*) : ;; # in a subdirectory — good + quickstart.md) : ;; # the entrypoint belongs at root + *) add_warning "openwiki/$rel sits at the wiki root; section pages belong in a topic subdirectory" ;; + esac + done < [json_str,json_str,...] + local out="" first=1 item + while IFS= read -r item; do + [ -z "$item" ] && continue + if [ "$first" -eq 1 ]; then + out="$(json_str "$item")" + first=0 + else + out="$out,$(json_str "$item")" + fi + done + printf '[%s]' "$out" +} + +problems_json=$(printf '%s' "$PROBLEMS" | json_array) +warnings_json=$(printf '%s' "$WARNINGS" | json_array) + +ok=true +[ -n "$PROBLEMS" ] && ok=false + +printf '{"ok":%s,"pages":%s,"problems":%s,"warnings":%s}\n' \ + "$ok" "$pages_count" "$problems_json" "$warnings_json" +exit 0 diff --git a/tests/check-format.test.ts b/tests/check-format.test.ts new file mode 100644 index 0000000..4ee0a51 --- /dev/null +++ b/tests/check-format.test.ts @@ -0,0 +1,291 @@ +import { mkdir, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, test } from "vitest"; +import { + createRepoWithOpenWiki, + runScript, + runScriptJson, +} from "./helpers/fixtures.ts"; + +// check-format.sh is a parity-enforcement gate: it verifies an openwiki/ folder +// conforms to references/wiki-format.md (the OpenWiki format contract) before +// the init/update skills write state. These tests exercise the verdict object +// — every hard parity violation must surface as a `problem`, soft observations +// as `warnings`, and a missing openwiki/ dir as a precondition failure (exit 2). + +type Verdict = { + ok: boolean; + pages: number; + problems: string[]; + warnings: string[]; +}; + +const check = (repo: string) => + runScriptJson("check-format.sh", repo); + +/** Overwrite a page in openwiki/, creating parent dirs as needed. */ +async function writePage( + repo: string, + rel: string, + content: string, +): Promise { + const full = path.join(repo, "openwiki", rel); + await mkdir(path.dirname(full), { recursive: true }); + await writeFile(full, content, "utf8"); +} + +/** A well-formed quickstart with the two required linking headings. */ +const QUICKSTART_OK = [ + "# Quickstart", + "", + "What this repository does.", + "", + "## Start here", + "", + "- [Architecture overview](./architecture/overview.md) — the big picture", + "", + "## Documentation map", + "", + "- Architecture → ./architecture/overview.md", + "", +].join("\n"); + +/** A well-formed section page: H1 first, resolving links, valid source map. */ +const OVERVIEW_OK = [ + "# Architecture overview", + "", + "Intro paragraph.", + "", + "## Details", + "", + "Back to [home](../quickstart.md).", + "", + "## Source map", + "", + "- `src/a.ts`", + "- Git evidence: commits `ceded10`, `f89b05d`", + "", +].join("\n"); + +describe("check-format.sh", () => { + test("a well-formed multi-page wiki reports ok with no problems", async () => { + const repo = await createRepoWithOpenWiki(); + await writePage(repo, "quickstart.md", QUICKSTART_OK); + await writePage(repo, "architecture/overview.md", OVERVIEW_OK); + + const v = await check(repo); + expect(v.ok).toBe(true); + expect(v.pages).toBe(2); + expect(v.problems).toEqual([]); + }); + + test("a YAML-frontmatter page is a problem (first line must be an H1)", async () => { + const repo = await createRepoWithOpenWiki(); + await writePage( + repo, + "quickstart.md", + "---\ntitle: Quickstart\n---\n# Quickstart\n", + ); + const v = await check(repo); + expect(v.ok).toBe(false); + expect(v.problems.some((p) => /quickstart\.md/.test(p) && /---|frontmatter/.test(p))).toBe(true); + }); + + test("a page whose first line is not an H1 is a problem", async () => { + const repo = await createRepoWithOpenWiki(); + await writePage(repo, "quickstart.md", "Not a heading\n# Quickstart\n"); + const v = await check(repo); + expect(v.ok).toBe(false); + expect( + v.problems.some((p) => /quickstart\.md/.test(p) && /level-1 heading/.test(p)), + ).toBe(true); + }); + + test("a broken relative link is a problem; a resolving one is not", async () => { + const repo = await createRepoWithOpenWiki(); + await writePage(repo, "quickstart.md", QUICKSTART_OK); + await writePage( + repo, + "architecture/overview.md", + "# Architecture overview\n\nSee [gone](./missing.md) and [home](../quickstart.md).\n", + ); + const v = await check(repo); + expect(v.ok).toBe(false); + const brokenLinkProblems = v.problems.filter((p) => /broken relative link/.test(p)); + expect(brokenLinkProblems.length).toBe(1); + expect(brokenLinkProblems[0]).toContain("./missing.md"); + }); + + test("external and anchor links are ignored, not flagged", async () => { + const repo = await createRepoWithOpenWiki(); + await writePage( + repo, + "quickstart.md", + "# Quickstart\n\nSee [ext](https://example.com/x) and [top](#start).\n", + ); + const v = await check(repo); + expect(v.ok).toBe(true); + expect(v.problems).toEqual([]); + }); + + test("a Git evidence bullet with a 40-char hash is a problem", async () => { + const repo = await createRepoWithOpenWiki(); + await writePage(repo, "quickstart.md", QUICKSTART_OK); + await writePage( + repo, + "architecture/overview.md", + [ + "# Architecture overview", + "", + "[home](../quickstart.md)", + "", + "## Source map", + "", + "- `src/a.ts`", + "- Git evidence: commits `abcdef0123456789abcdef0123456789abcdef01`", + "", + ].join("\n"), + ); + const v = await check(repo); + expect(v.ok).toBe(false); + expect(v.problems.some((p) => /malformed 'Git evidence:'/.test(p))).toBe(true); + }); + + test("a Git evidence bullet missing backticks is a problem", async () => { + const repo = await createRepoWithOpenWiki(); + await writePage(repo, "quickstart.md", QUICKSTART_OK); + await writePage( + repo, + "architecture/overview.md", + [ + "# Architecture overview", + "", + "[home](../quickstart.md)", + "", + "## Source map", + "", + "- `src/a.ts`", + "- Git evidence: commits ceded10", + "", + ].join("\n"), + ); + const v = await check(repo); + expect(v.ok).toBe(false); + expect(v.problems.some((p) => /malformed 'Git evidence:'/.test(p))).toBe(true); + }); + + test("a Git evidence bullet that is not the last bullet is a problem", async () => { + const repo = await createRepoWithOpenWiki(); + await writePage(repo, "quickstart.md", QUICKSTART_OK); + await writePage( + repo, + "architecture/overview.md", + [ + "# Architecture overview", + "", + "[home](../quickstart.md)", + "", + "## Source map", + "", + "- Git evidence: commits `ceded10`", + "- `src/a.ts`", + "", + ].join("\n"), + ); + const v = await check(repo); + expect(v.ok).toBe(false); + expect(v.problems.some((p) => /not the last bullet/.test(p))).toBe(true); + }); + + test("a mis-cased source-map heading is a problem", async () => { + const repo = await createRepoWithOpenWiki(); + await writePage(repo, "quickstart.md", QUICKSTART_OK); + await writePage( + repo, + "architecture/overview.md", + [ + "# Architecture overview", + "", + "[home](../quickstart.md)", + "", + "## Source Map", + "", + "- `src/a.ts`", + "", + ].join("\n"), + ); + const v = await check(repo); + expect(v.ok).toBe(false); + expect(v.problems.some((p) => /mis-cased source-map heading/.test(p))).toBe(true); + }); + + test("a leftover _plan.md is a problem and is not counted as a page", async () => { + const repo = await createRepoWithOpenWiki(); + await writePage(repo, "quickstart.md", "# Quickstart\n"); + await writePage(repo, "_plan.md", "# Plan\n"); + const v = await check(repo); + expect(v.ok).toBe(false); + expect(v.pages).toBe(1); + expect(v.problems.some((p) => /_plan\.md is still present/.test(p))).toBe(true); + }); + + test("2+ pages without '## Start here' / '## Documentation map' is a problem", async () => { + const repo = await createRepoWithOpenWiki(); + await writePage(repo, "quickstart.md", "# Quickstart\n\nNo linking headings.\n"); + await writePage(repo, "architecture/overview.md", "# Architecture overview\n"); + const v = await check(repo); + expect(v.ok).toBe(false); + expect(v.problems.some((p) => /Start here/.test(p))).toBe(true); + expect(v.problems.some((p) => /Documentation map/.test(p))).toBe(true); + }); + + test("a missing quickstart.md is a problem", async () => { + const repo = await createRepoWithOpenWiki(); + await rm(path.join(repo, "openwiki", "quickstart.md")); + await writePage(repo, "architecture/overview.md", "# Architecture overview\n"); + const v = await check(repo); + expect(v.ok).toBe(false); + expect(v.problems.some((p) => /quickstart\.md is missing/.test(p))).toBe(true); + }); + + test("nine well-formed pages report ok with the soft-ceiling warning", async () => { + const repo = await createRepoWithOpenWiki(); + // Quickstart with linking headings but no dangling links (the section pages + // it would link to are validated for existence by the checker). + await writePage( + repo, + "quickstart.md", + "# Quickstart\n\n## Start here\n\n- [Page 1](./topic/p1.md) — first\n\n## Documentation map\n\n- Topic → ./topic/p1.md\n", + ); + // Add 8 valid section pages (9 total) to cross the soft ceiling of 8. + for (let i = 1; i <= 8; i++) { + await writePage(repo, `topic/p${i}.md`, `# Page ${i}\n`); + } + const v = await check(repo); + expect(v.ok).toBe(true); + expect(v.pages).toBe(9); + expect(v.warnings.some((w) => /soft init ceiling is 8/.test(w))).toBe(true); + }); + + test("a non-quickstart page at the wiki root is a warning, not a problem", async () => { + const repo = await createRepoWithOpenWiki(); + await writePage( + repo, + "quickstart.md", + "# Quickstart\n\n## Start here\n\n- [Real](./topic/real.md) — a page\n\n## Documentation map\n\n- Topic → ./topic/real.md\n", + ); + await writePage(repo, "stray.md", "# Stray\n"); + await writePage(repo, "topic/real.md", "# Real\n"); + const v = await check(repo); + expect(v.ok).toBe(true); + expect(v.warnings.some((w) => /stray\.md.*wiki root/.test(w))).toBe(true); + }); + + test("a repo with no openwiki/ dir fails the precondition (exit 2)", async () => { + const repo = await createRepoWithOpenWiki(); + await rm(path.join(repo, "openwiki"), { recursive: true }); + const { code, stderr } = await runScript("check-format.sh", repo); + expect(code).toBe(2); + expect(stderr).toMatch(/openwiki does not exist/); + }); +}); From 16962f0a75c80cd2992f3d194657133ae3d38d47 Mon Sep 17 00:00:00 2001 From: Gerlando Piro Date: Wed, 8 Jul 2026 11:17:29 +0200 Subject: [PATCH 2/4] feat: wire format gate into init/update, tighten git tool allowlists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run scripts/check-format.sh as a blocking step before init and update record state, so a wiki that has drifted from the format contract never gets committed — matching how the algorithm side already blocks on check-noop.sh. Both skills now hard-stop on ok:false and treat warnings as judgment calls. While touching allowed-tools, narrow the blanket Bash(git *) grant down to the read-only subset the skills' disciplines actually need (log/show/diff/status/blame/rev-parse/rev-list/cat-file/ls-files/ shortlog). Git writes (add/commit/push/checkout) belong to the agent pipeline, not a skill invoked mid-conversation. Co-Authored-By: Claude Fable 5 --- skills/init/SKILL.md | 22 +++++++++++++++++++--- skills/update/SKILL.md | 20 +++++++++++++++++--- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/skills/init/SKILL.md b/skills/init/SKILL.md index f660f01..a2a8d94 100644 --- a/skills/init/SKILL.md +++ b/skills/init/SKILL.md @@ -3,7 +3,7 @@ name: init description: Generate the wijzer/OpenWiki wiki for this repository from scratch into openwiki/, then add a pointer block to AGENTS.md / CLAUDE.md. Use when the user runs /wijzer:init or asks to create/bootstrap the repository wiki. argument-hint: [focus] disable-model-invocation: true -allowed-tools: Bash(${CLAUDE_PLUGIN_ROOT}/scripts/*), Bash(git *), Bash(rg *), Bash(rm -f openwiki/_plan.md), Read, Grep, Glob, Write, Edit, Task +allowed-tools: Bash(${CLAUDE_PLUGIN_ROOT}/scripts/*), Bash(git log:*), Bash(git show:*), Bash(git diff:*), Bash(git status:*), Bash(git blame:*), Bash(git rev-parse:*), Bash(git rev-list:*), Bash(git cat-file:*), Bash(git ls-files:*), Bash(git shortlog:*), Bash(rg *), Bash(rm -f openwiki/_plan.md), Read, Grep, Glob, Write, Edit, Task --- # /wijzer:init — generate the wiki from scratch @@ -64,7 +64,22 @@ repo is clearly tiny; merge thin pages rather than shipping stubs. rm -f openwiki/_plan.md ``` -**7. Pointer block.** Point coding agents at the wiki idempotently: +**7. Parity gate.** Verify the wiki conforms to the format contract before doing +anything else: + +```bash +"${CLAUDE_PLUGIN_ROOT}/scripts/check-format.sh" --dir . +``` + +If `ok` is `false`, the wiki violates `wiki-format.md`. Fix **each** string in +`problems` directly in the affected pages (broken links, missing `## Start +here` / `## Documentation map`, frontmatter, malformed `## Source map` / +`Git evidence:` bullets, a leftover `_plan.md`) and re-run until `ok` is `true`. +Entries in `warnings` (e.g. the 8-page soft ceiling, a page at the wiki root) +are judgment calls, not blockers — weigh them but you need not act on them. +**Do not proceed to the pointer or state steps while the gate reports `ok:false`.** + +**8. Pointer block.** Point coding agents at the wiki idempotently: ```bash "${CLAUDE_PLUGIN_ROOT}/scripts/inject-pointer.sh" --dir . @@ -73,7 +88,8 @@ rm -f openwiki/_plan.md This creates or appends a marker-delimited block in `AGENTS.md` / `CLAUDE.md` (safe to re-run). Report its `results` to the user. -**8. Record state.** Only after the wiki content exists, write the run metadata: +**9. Record state.** Only after the wiki content exists and the parity gate +passes, write the run metadata: ```bash "${CLAUDE_PLUGIN_ROOT}/scripts/write-state.sh" --dir . --command init --model diff --git a/skills/update/SKILL.md b/skills/update/SKILL.md index 474c64b..ffdf8fe 100644 --- a/skills/update/SKILL.md +++ b/skills/update/SKILL.md @@ -3,7 +3,7 @@ name: update description: Refresh the wijzer/OpenWiki wiki from what changed in the repository since the last run, making surgical edits and no-opping cleanly when nothing meaningful changed. Use when the user runs /wijzer:update or asks to refresh/sync the wiki. Supports --dry-run to preview without writing. argument-hint: [--dry-run] [instruction] disable-model-invocation: true -allowed-tools: Bash(${CLAUDE_PLUGIN_ROOT}/scripts/*), Bash(git *), Bash(rg *), Bash(rm -f openwiki/_plan.md), Read, Grep, Glob, Write, Edit, Task +allowed-tools: Bash(${CLAUDE_PLUGIN_ROOT}/scripts/*), Bash(git log:*), Bash(git show:*), Bash(git diff:*), Bash(git status:*), Bash(git blame:*), Bash(git rev-parse:*), Bash(git rev-list:*), Bash(git cat-file:*), Bash(git ls-files:*), Bash(git shortlog:*), Bash(rg *), Bash(rm -f openwiki/_plan.md), Read, Grep, Glob, Write, Edit, Task --- # /wijzer:update — refresh the wiki from recent changes @@ -85,14 +85,28 @@ Compare to the step-3 `digest`: runs don't churn a PR. Report "wiki already accurate — no changes". - **Changed** → continue to step 6. -**6. Pointer block.** Re-run the idempotent injector (picks up a newly added +**6. Parity gate.** Now that content actually changed, verify it still conforms +to the format contract: + +```bash +"${CLAUDE_PLUGIN_ROOT}/scripts/check-format.sh" --dir . +``` + +If `ok` is `false`, fix **each** string in `problems` in the affected pages +(broken links, missing quickstart linking headings, frontmatter, malformed +`## Source map` / `Git evidence:` bullets) and re-run until `ok` is `true`; +`warnings` are judgment calls, not blockers. **Do not run the pointer or state +steps while the gate reports `ok:false`.** (This gate does not run in the +`--dry-run` or no-op paths — those already stopped above.) + +**7. Pointer block.** Re-run the idempotent injector (picks up a newly added `AGENTS.md`/`CLAUDE.md`, no-ops otherwise): ```bash "${CLAUDE_PLUGIN_ROOT}/scripts/inject-pointer.sh" --dir . ``` -**7. Record state.** +**8. Record state.** ```bash "${CLAUDE_PLUGIN_ROOT}/scripts/write-state.sh" --dir . --command update --model From e33d980ef518862803b04a3a664168e1d81e1c99 Mon Sep 17 00:00:00 2001 From: Gerlando Piro Date: Wed, 8 Jul 2026 11:17:36 +0200 Subject: [PATCH 3/4] refactor: pin wiki-scout to durable sonnet alias, lock allowlist doctrine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch wiki-scout's model from a pinned dated id to the durable `sonnet` alias so the scout tracks the current Sonnet without needing a version-bump edit every release. Document why its Bash grant can't be narrowed to a read-only git subset the way the skills' can — agent `tools:` frontmatter only accepts bare tool names, not per-command specifiers like `Bash(git log:*)` — and note the upgrade path if Claude Code adds that support. tests/plugin-structure.test.ts locks both the read-only-git doctrine for init/update (no Bash(git *) catch-all, no commit/push/checkout grant) and the wiki-scout alias/read-only claims, so a future edit can't silently re-widen either allowlist. Co-Authored-By: Claude Fable 5 --- agents/wiki-scout.md | 8 +++++- tests/plugin-structure.test.ts | 47 ++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/agents/wiki-scout.md b/agents/wiki-scout.md index e17d88c..da00c18 100644 --- a/agents/wiki-scout.md +++ b/agents/wiki-scout.md @@ -1,8 +1,14 @@ --- name: wiki-scout description: Read-only repository discovery agent for wijzer. Fans out during /wijzer:init and /wijzer:update to inspect one domain of a codebase — source, docs, data model, API surface, integrations, tests, or git history — and returns concise findings with source paths and open questions. Never writes files. -model: claude-sonnet-4-6 +model: sonnet effort: medium +# Agent `tools:` frontmatter accepts only bare tool names — it does not support +# per-command permission specifiers (e.g. `Bash(git log:*)`), unlike a skill's +# `allowed-tools`. So Bash cannot be narrowed to a non-mutating git subset here; +# the read-only constraint below (and the plain-git commands the brief needs) is +# what keeps this scout non-mutating. If Claude Code later supports restricted +# Bash specifiers in agent `tools:`, tighten this to the git read subset. tools: Read, Grep, Glob, Bash --- diff --git a/tests/plugin-structure.test.ts b/tests/plugin-structure.test.ts index 236dbe8..835f788 100644 --- a/tests/plugin-structure.test.ts +++ b/tests/plugin-structure.test.ts @@ -93,6 +93,29 @@ describe("skills", () => { const md = await read("skills", "update", "SKILL.md"); expect(md).toContain("--dry-run"); }); + + // The write-capable skills may run git only for read-only history inspection. + // Their allowed-tools must NOT carry a broad `Bash(git *)` / `Bash(git:*)` + // catch-all that would let the model run `git commit`/`git push`/`git checkout` + // directly — those belong to the agent pipeline, not the skill. This locks the + // doctrine so a future edit can't silently re-widen the git allowlist. + for (const name of ["init", "update"] as const) { + test(`${name} allowed-tools has no broad git catch-all and cannot commit/push`, async () => { + const md = await read("skills", name, "SKILL.md"); + const { fm } = splitFrontmatter(md); + const allowed = fmField(fm, "allowed-tools") ?? ""; + // No wildcard-only git grant in either specifier style. + expect(allowed).not.toMatch(/Bash\(git\s*\*\)/); // `Bash(git *)` + expect(allowed).not.toMatch(/Bash\(git:\*\)/); // `Bash(git:*)` + // No mutating git subcommand is granted. + expect(allowed).not.toMatch(/Bash\(git commit/); + expect(allowed).not.toMatch(/Bash\(git push/); + expect(allowed).not.toMatch(/Bash\(git checkout/); + // The read-only subset the disciplines actually need is present. + expect(allowed).toMatch(/Bash\(git log:\*\)/); + expect(allowed).toMatch(/Bash\(git show:\*\)/); + }); + } }); describe("wiki-scout agent", () => { @@ -104,6 +127,30 @@ describe("wiki-scout agent", () => { expect(tools).not.toMatch(/\bWrite\b/); expect(tools).not.toMatch(/\bEdit\b/); }); + + test("runs on the durable 'sonnet' model alias, not a pinned dated id", async () => { + const md = await read("agents", "wiki-scout.md"); + const { fm } = splitFrontmatter(md); + // Durable alias so the scout tracks the current Sonnet without a version bump. + expect(fmField(fm, "model")).toBe("sonnet"); + }); + + test("its Bash use is bounded to non-mutating commands by the agent prose", async () => { + // Agent `tools:` frontmatter accepts only bare tool names — it cannot carry + // per-command permission specifiers like a skill's allowed-tools can — so + // the scout lists a bare `Bash`. The enforcement that keeps it read-only is + // the explicit non-mutation contract in its body, which this asserts stays + // present (and that no write tools leaked into `tools:`). + const md = await read("agents", "wiki-scout.md"); + const { fm, body } = splitFrontmatter(md); + const tools = fmField(fm, "tools") ?? ""; + expect(tools).not.toMatch(/\bWrite\b/); + expect(tools).not.toMatch(/\bEdit\b/); + // The body must forbid the mutating git operations and file writes. + expect(body).toMatch(/Read-only/); + expect(body).toMatch(/git add\/commit\/checkout/); + expect(body).toMatch(/non-mutating/); + }); }); describe("reference doctrine", () => { From 64b2475ffc20a4bc69a462dc48da095f92e00ec3 Mon Sep 17 00:00:00 2001 From: Gerlando Piro Date: Wed, 8 Jul 2026 11:17:42 +0200 Subject: [PATCH 4/4] docs: record format-gate parity row and pr_automerge policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PARITY.md: point the format-parity row at the new check-format.sh verifier (was doctrine-only) and add a watch item flagging that check-noop.sh parses openwiki/.last-update.json with sed rather than a JSON parser — the one seam where interchangeability depends on parsing JSON that OpenWiki itself wrote, to be re-checked first if upstream's serializer output ever changes shape. CLAUDE.md: document the format gate in the architecture blurb and add the pr_automerge Agent Config key — pr-monitor now merges feature PRs automatically once CI is green, no human approval gate. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 5 ++++- PARITY.md | 13 ++++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 75d486c..a49bd78 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,9 @@ Deterministic bookkeeping lives in **`scripts/`** (dependency-free bash: git + coreutils); each script emits one JSON object on stdout (exit 0 = ran, 2 = precondition missing). Model judgment lives in **`skills/`** (`/wijzer:init`, `:update`, `:ask`) and **`agents/`** (`wiki-scout`, read-only fan-out). Shared -doctrine is in **`references/`**. The scripts are unit-tested against real temp +doctrine is in **`references/`**. The format side of parity is gated +deterministically too: init/update finish by running `scripts/check-format.sh` +over `openwiki/` and must fix reported problems before recording state. The scripts are unit-tested against real temp git repos in **`tests/`** (Vitest); `tests/noop.test.ts` is a case-for-case port of OpenWiki's `test/update-noop.test.ts` — the executable parity spec. @@ -38,6 +40,7 @@ state file stays `openwiki/.last-update.json` with the exact | test_pattern | `scripts/.sh` → `tests/.test.ts` | | branch_pattern | `claude/` (off `origin/main`) | | pr_merge_strategy | squash + delete branch | +| pr_automerge | on green — pr-monitor merges feature PRs automatically once all CI checks pass (squash + delete branch); no human approval gate | ## Conventions diff --git a/PARITY.md b/PARITY.md index b176111..a48c330 100644 --- a/PARITY.md +++ b/PARITY.md @@ -28,7 +28,7 @@ living record of what that means and how it is verified. | `openwiki --update [msg]` | `/wijzer:update [msg]` skill | `tests/parity-crossvalidate.test.ts` + Phase-3 scenarios | | chat mode (Q&A, no writes) | `/wijzer:ask` skill | manual: assert zero wiki writes | | `--print` non-interactive | `claude -p "/wijzer:update"` | headless recipe (Phase 4) | -| plain-MD pages, no frontmatter, source-map at page end, ≤8 pages on init | `references/wiki-format.md` | format checklist vs upstream `openwiki/` | +| plain-MD pages, no frontmatter, source-map at page end, ≤8 pages on init | `references/wiki-format.md` + `scripts/check-format.sh` gate in init/update | `tests/check-format.test.ts` + golden run vs upstream `openwiki/` | | `.last-update.json` = {updatedAt, command, gitHead?, model} | `scripts/write-state.sh` | `tests/state.test.ts` (CLI contract) + `tests/parity-crossvalidate.test.ts` (real-function interchange) | | no-op: (no msg AND HEAD==state) OR only `openwiki/` changed; force when dirty | `scripts/check-noop.sh` (ports `getUpdateNoopStatus` + `shouldCheckUpdateNoop`) | `tests/parity-crossvalidate.test.ts` runs bash vs the vendored real functions; `vendor/openwiki/test/update-noop.test.ts` runs verbatim against the vendored source | | surgical edits: ≤1–2 pages when <5 files changed | `references/disciplines.md` + `scripts/diff-summary.sh` | Phase-3 scenario | @@ -38,6 +38,17 @@ living record of what that means and how it is verified. | idempotent AGENTS.md/CLAUDE.md block | `scripts/inject-pointer.sh` | `tests/inject.test.ts` | | GH Action: cron 8am → update → PR `openwiki/update` | `examples/github-action.yml` (via anthropics/claude-code-action, subscription OAuth) | Phase-4 live run | +## Watch items + +- **State-file parsing seam.** `scripts/check-noop.sh` extracts `gitHead` from + `openwiki/.last-update.json` with `sed`, not a JSON parser (the scripts are + dependency-free by design). This is the one place interchangeability depends + on parsing JSON that *OpenWiki* may have written. Mitigated by + `tests/parity-crossvalidate.test.ts`, which runs `check-noop.sh` over state + files the vendored real OpenWiki functions produce; if upstream ever changes + its serializer (multi-line output, key reordering across lines), re-check this + seam first during re-validation. + ## Re-validation procedure (when parity-watch fires) 1. Re-vendor the pinned spec source: `scripts/vendor-openwiki.sh --sha `.