diff --git a/CHANGELOG.md b/CHANGELOG.md index 10f6a10..11375e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,17 +8,19 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added -- **Phase 1 — deterministic core.** Plugin scaffold (`.claude-plugin/`), the six - bash scripts that own all deterministic bookkeeping (`check-noop`, `snapshot`, - `write-state`, `diff-summary`, `inventory`, `inject-pointer`), and a Vitest - suite that exercises them against real temporary git repos. `tests/noop.test.ts` - is a case-for-case port of OpenWiki's `test/update-noop.test.ts` (the executable - parity spec). CI runs shellcheck + tests on macOS and Linux. +- **Phase 1 — deterministic core.** Plugin scaffold (`.claude-plugin/`), the + bash scripts that own the deterministic no-op / snapshot / state bookkeeping + (`check-noop`, `snapshot`, `write-state`), and a Vitest suite that exercises + them against real temporary git repos. The executable parity spec is OpenWiki's + own `test/update-noop.test.ts`, run verbatim from the vendored source, plus + `tests/parity-crossvalidate.test.ts` (wijzer's bash vs the real functions). CI + runs shellcheck + tests on macOS and Linux. (Phase 1 also shipped `inventory`, + `inject-pointer`, and `diff-summary`; P2D removed them — see Changed below.) - `PARITY.md` pinning the validated upstream OpenWiki commit and the mapping table. - **Phase 2 — init skill.** `/wijzer:init [focus]` (`skills/init/SKILL.md`) that - runs the deterministic inventory, optionally fans out read-only `wiki-scout` + discovers the repository, optionally fans out read-only `wiki-scout` subagents (`agents/wiki-scout.md`), plans via `openwiki/_plan.md`, writes the - wiki, injects the `AGENTS.md`/`CLAUDE.md` pointer, and records state. The + wiki, adds the `AGENTS.md`/`CLAUDE.md` pointer section, and records state. The parity contract is captured in `references/wiki-format.md` (page format, source maps, ≤8-page ceiling) and `references/disciplines.md` (run / subagent / planning / git / surgical-edit disciplines). @@ -53,3 +55,24 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). output rather than its prompt. `references/state-schema.md` stays hand-authored (it documents wijzer-only serialization facts) but its field set is now locked to the vendored `UpdateMetadata` type by the same test. + +### Changed + +- **P2D — prompt-driven skills (parity-first).** Removed the three bash scripts + that had no OpenWiki counterpart and were wijzer value-adds: `inventory.sh` + (repo inventory), `inject-pointer.sh` (marker-block writer), and + `diff-summary.sh` (bespoke JSON diff), plus their tests. Discovery, git + inspection, and the `AGENTS.md`/`CLAUDE.md` pointer are now **prompt-driven**, + matching OpenWiki's own behavior: + - `/wijzer:init` and `/wijzer:update` (`skills/init`, `skills/update`) are thin + wrappers: they follow the generated run/git disciplines directly and call + only the exact-semantics bookkeeping scripts (`check-noop`, `snapshot`, + `write-state`, `check-format`). The update skill reads the baseline from + `openwiki/.last-update.json` and runs the same `git status`/`log `/`diff` + commands and the same `gitHead` → `updatedAt` → recent-history fallback as + OpenWiki's `createGitSummary`. + - The pointer is written by the agent using OpenWiki's **exact `## OpenWiki` + block**, now preserved byte-for-byte in `references/disciplines.md` (fenced + literals are exempt from vocabulary translation and the residual-vocab guard). + This reverses P2C's inject-pointer.sh adaptation. + - `agents/wiki-scout.md` is aligned to the generated subagent discipline. diff --git a/CLAUDE.md b/CLAUDE.md index c1c07fe..1076c3d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,8 +20,9 @@ translation, drift-locked by `tests/build-disciplines.test.ts`. Edit the generator and re-run it, never the generated files. 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. +git repos in **`tests/`** (Vitest); the vendored `vendor/openwiki/test/update-noop.test.ts` +runs verbatim and `tests/parity-crossvalidate.test.ts` runs wijzer's bash against +the real OpenWiki functions — the executable parity spec. Interchangeability with OpenWiki means the wiki dir stays `openwiki/` and the state file stays `openwiki/.last-update.json` with the exact diff --git a/PARITY.md b/PARITY.md index 82aff22..cc6d594 100644 --- a/PARITY.md +++ b/PARITY.md @@ -31,11 +31,11 @@ living record of what that means and how it is verified. | 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 | +| git evidence (`git status`/`log `/`diff`) → surgical edits: ≤1–2 pages when <5 files changed | `references/disciplines.md` (git discipline) — **prompt-driven**: the update skill runs the same commands and the same `gitHead` → `updatedAt` → recent-history fallback as OpenWiki's `createGitSummary`, reading the baseline from `openwiki/.last-update.json` | Phase-3 scenario | | SHA-256 snapshot; write state only if content changed | `scripts/snapshot.sh` (`dir:`/`file:` frames match real byte-for-byte) + update-skill gate | `tests/snapshot.test.ts` (envelope) + `tests/parity-crossvalidate.test.ts` (digest equals real) | -| init: inventory → `_plan.md` → generate → delete plan → state | `scripts/inventory.sh` + init skill | golden run | +| init: discover → `_plan.md` → generate → delete plan → state | **prompt-driven** discovery (run discipline) + init skill; no bash inventory step (OpenWiki has none) | golden run | | run/subagent/planning/git disciplines (`src/agent/prompt.ts`) | `references/disciplines.md` + `references/wiki-format.md` (both **generated** from the vendored `prompt.ts` by `scripts/build-disciplines.mjs`) + `agents/wiki-scout.md` | `tests/build-disciplines.test.ts` — drift-locked: the committed docs must equal a fresh regenerate, so an upstream prompt change fails CI until re-derived | -| idempotent AGENTS.md/CLAUDE.md block | `scripts/inject-pointer.sh` | `tests/inject.test.ts` | +| exact `## OpenWiki` block in AGENTS.md/CLAUDE.md | **prompt-driven**: the agent writes OpenWiki's verbatim `## OpenWiki` section (preserved byte-for-byte in `references/disciplines.md`) — no bash injector | `tests/build-disciplines.test.ts` (block derived verbatim) | | 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 diff --git a/README.md b/README.md index 6519983..fa92a58 100644 --- a/README.md +++ b/README.md @@ -49,13 +49,15 @@ with a subscription OAuth token (`claude setup-token`) — no API key in CI eith wijzer splits deterministic bookkeeping from model judgment: -- **`scripts/`** — dependency-free bash (git + coreutils) that computes the - update no-op verdict, the content snapshot, the diff summary, the repo - inventory, the state file, and the pointer injection. Each emits one JSON - object; each is unit-tested against real temp git repos (`npm test`). -- **`skills/` + `agents/`** — the model does discovery, page planning, and - writing, consuming the scripts' JSON and obeying the disciplines in - `references/`. +- **`scripts/`** — dependency-free bash (git + coreutils) that owns the + exact-semantics bookkeeping: the update no-op verdict (`check-noop`), the + content snapshot (`snapshot`), the state file (`write-state`), and the output + format gate (`check-format`). Each emits one JSON object; each is unit-tested + against real temp git repos (`npm test`). +- **`skills/` + `agents/`** — the model does discovery, git inspection, page + planning, writing, and the `AGENTS.md`/`CLAUDE.md` pointer **prompt-driven**, + obeying the disciplines in `references/` (generated from OpenWiki's own prompt) + and calling the scripts above for the bookkeeping. See [PARITY.md](PARITY.md) for exactly what matches OpenWiki and how it's verified, and [`CONTRIBUTING`](#contributing) below to hack on it. diff --git a/agents/wiki-scout.md b/agents/wiki-scout.md index da00c18..088c596 100644 --- a/agents/wiki-scout.md +++ b/agents/wiki-scout.md @@ -20,6 +20,13 @@ brief**. Your entire job is to inspect that slice of the repo and report back. You do not write documentation and you do not decide the wiki's structure — the main agent synthesizes every page. +You are the concrete form of the **subagent discipline** in +`references/disciplines.md` (derived from OpenWiki's prompt): parallelize +read-only research over a narrow brief — existing docs, runtime architecture, +data/storage, UI/API surface, integrations, tests/evals, or business workflows — +inspect and summarize only, and return concise findings with source paths and +open questions for the main agent to synthesize. + ## Absolute constraints - **Read-only. Never mutate anything.** Do not create, edit, move, or delete diff --git a/references/disciplines.md b/references/disciplines.md index 928552b..2bfb33d 100644 --- a/references/disciplines.md +++ b/references/disciplines.md @@ -58,22 +58,27 @@ Use only the tools available to you. Prefer built-in discovery tools such as `Gl ## Root agent instruction files -*Distribution-method adaptation: OpenWiki has the agent hand-write this -pointer block; wijzer writes it deterministically with -[`scripts/inject-pointer.sh`](../scripts/inject-pointer.sh). The parity intent — -a top-level, idempotent pointer into the wiki — is preserved; the exact -`## OpenWiki` block OpenWiki embeds here is replaced by the script's -marker-delimited block.* - -- Point coding agents at the wiki from the repository's **top-level** - `AGENTS.md` / `CLAUDE.md` — never nested `AGENTS.md`/`CLAUDE.md` files. -- Do not hand-write the block. Run `scripts/inject-pointer.sh`, which creates - or updates a marker-delimited block idempotently (safe to re-run) and - preserves the surrounding content. -- On update runs, re-run `scripts/inject-pointer.sh` so a repository that - gained an `AGENTS.md`/`CLAUDE.md` since init picks up the block; it no-ops - when the block is already present. -- Do not make formatting-only edits to these files. +- Unless the user explicitly asks you not to, always make sure the repository's top-level agent instruction files reference the OpenWiki quickstart. +- Only consider top-level AGENTS.md and CLAUDE.md for this step. Do not edit nested AGENTS.md or CLAUDE.md files. +- If AGENTS.md or CLAUDE.md exists, add or update the OpenWiki reference section there. If both exist, ensure the same section is added to both (duplicated). +- If neither exists, create top-level AGENTS.md containing only the OpenWiki reference section. +- During update runs, inspect any existing OpenWiki reference section in AGENTS.md and/or CLAUDE.md and refresh it only if the section is missing or semantically stale. This check is required even when the wiki itself is otherwise current. +- Preserve surrounding instructions in existing files. Replace/update an existing OpenWiki reference section instead of adding duplicates. +- Do not edit AGENTS.md or CLAUDE.md only to normalize formatting, blank lines, wrapping, or punctuation if the existing OpenWiki section is already semantically correct. +- Use this exact section structure every time: + +```markdown +## OpenWiki + +This repository has documentation located in the /openwiki directory. + +Start here: +- [OpenWiki quickstart](openwiki/quickstart.md) + +OpenWiki includes repository overview, architecture notes, workflows, domain concepts, operations, integrations, testing guidance, and source maps. + +When working in this repository, read the OpenWiki quickstart first, then follow its links to the relevant architecture, workflow, domain, operation, and testing notes. +``` ## Security and privacy rules @@ -164,12 +169,13 @@ filesystem to Claude Code's real tools: - Leading-slash openwiki path. - matches `\/openwiki` -Two sections need more than a vocabulary swap: +Two more rules beyond the vocabulary swap: - `OpenWiki CLI reference:` is **dropped** — its subject, the `openwiki` CLI flag surface, is out of wijzer's parity scope, since wijzer's runtime is Claude Code skills (`/wijzer:init`, `:update`, `:ask`), not a CLI. -- `Root agent instruction files:` is **adapted** — OpenWiki has the agent - hand-write an `## OpenWiki` pointer block; wijzer writes a marker-delimited - block deterministically with `scripts/inject-pointer.sh`, so the parity-relevant - rules are kept but the write mechanism and embedded block are replaced. +- Fenced ```code blocks are **preserved verbatim** — not translated and not + residual-vocab-checked. The `## OpenWiki` pointer block under "Root agent + instruction files" is the exact literal the agent must reproduce byte-for-byte + into a repository's AGENTS.md/CLAUDE.md, so it keeps OpenWiki's own `/openwiki` + path for an interchangeable wiki. diff --git a/references/state-schema.md b/references/state-schema.md index 45d1cda..666b681 100644 --- a/references/state-schema.md +++ b/references/state-schema.md @@ -7,9 +7,9 @@ or change the field set. Deterministic ownership: the file is written only by [`scripts/write-state.sh`](../scripts/write-state.sh) and read (for `gitHead`) by -[`scripts/check-noop.sh`](../scripts/check-noop.sh) and -[`scripts/diff-summary.sh`](../scripts/diff-summary.sh). Skills never hand-write -it — they call `write-state.sh`. +[`scripts/check-noop.sh`](../scripts/check-noop.sh), which surfaces the prior +`stateGitHead` the update skill uses to scope its prompt-driven git inspection. +Skills never hand-write it — they call `write-state.sh`. ## Location @@ -86,8 +86,8 @@ Example (non-git directory — `gitHead` absent, model fell back): ## Reading it back -`check-noop.sh` and `diff-summary.sh` extract `gitHead` with a tolerant `sed` -(`"gitHead"\s*:\s*"…"`) rather than a JSON parser, so they read files written by +`check-noop.sh` extracts `gitHead` with a tolerant `sed` +(`"gitHead"\s*:\s*"…"`) rather than a JSON parser, so it reads files written by either tool regardless of whitespace or key order. A file with no `gitHead` yields the `"missing previous update git head"` verdict (`checkNoop: true`, `noop: false`) — i.e. wijzer treats a stateless or non-git wiki as always worth a diff --git a/references/wiki-format.md b/references/wiki-format.md index 7b8bd48..d23842e 100644 --- a/references/wiki-format.md +++ b/references/wiki-format.md @@ -132,12 +132,13 @@ filesystem to Claude Code's real tools: - Leading-slash openwiki path. - matches `\/openwiki` -Two sections need more than a vocabulary swap: +Two more rules beyond the vocabulary swap: - `OpenWiki CLI reference:` is **dropped** — its subject, the `openwiki` CLI flag surface, is out of wijzer's parity scope, since wijzer's runtime is Claude Code skills (`/wijzer:init`, `:update`, `:ask`), not a CLI. -- `Root agent instruction files:` is **adapted** — OpenWiki has the agent - hand-write an `## OpenWiki` pointer block; wijzer writes a marker-delimited - block deterministically with `scripts/inject-pointer.sh`, so the parity-relevant - rules are kept but the write mechanism and embedded block are replaced. +- Fenced ```code blocks are **preserved verbatim** — not translated and not + residual-vocab-checked. The `## OpenWiki` pointer block under "Root agent + instruction files" is the exact literal the agent must reproduce byte-for-byte + into a repository's AGENTS.md/CLAUDE.md, so it keeps OpenWiki's own `/openwiki` + path for an interchangeable wiki. diff --git a/scripts/build-disciplines.mjs b/scripts/build-disciplines.mjs index 884efc1..4d08f50 100644 --- a/scripts/build-disciplines.mjs +++ b/scripts/build-disciplines.mjs @@ -205,14 +205,42 @@ const TRANSLATIONS = [ { note: "Leading-slash openwiki path.", from: /\/openwiki/g, to: "openwiki" }, ]; -function translate(text) { - let out = text; - for (const { from, to } of TRANSLATIONS) { - out = out.replaceAll(from, to); +// Fenced ``` code blocks are verbatim literals — most importantly the exact +// `## OpenWiki` pointer block the agent must reproduce byte-for-byte into a repo's +// AGENTS.md/CLAUDE.md for an interchangeable wiki. They legitimately contain +// OpenWiki's own `/openwiki` path, so they are exempt from BOTH vocabulary +// translation and the residual-vocab guard. +const FENCE_RE = /```[\s\S]*?```/g; + +function withFencesProtected(text, fn) { + // A stray/odd ``` marker would mispair, silently exempting a prose span from + // both translation and the residual guard — fail loudly instead. + const markers = text.match(/```/g) ?? []; + if (markers.length % 2 !== 0) { + throw new Error(`odd number of \`\`\` fence markers (${markers.length}) — cannot pair`); + } + // Split into alternating non-fence chunks and the fence blocks between them; + // transform only the non-fence chunks, then reassemble in order. + const between = text.split(FENCE_RE); + const fences = text.match(FENCE_RE) ?? []; + let out = ""; + for (let i = 0; i < between.length; i++) { + out += fn(between[i]); + if (i < fences.length) out += fences[i]; } return out; } +function translate(text) { + return withFencesProtected(text, (masked) => { + let out = masked; + for (const { from, to } of TRANSLATIONS) { + out = out.replaceAll(from, to); + } + return out; + }); +} + // Tokens that must NOT survive translation into the doctrine body. This is a // backstop, not a completeness proof: only substrings appearing in a verbatim or // global rule are caught, so a *reworded* upstream sentence can pass through @@ -233,8 +261,9 @@ const RESIDUAL_VOCAB = [ ]; export function assertNoResidualVocab(text, where) { + const stripped = text.replace(FENCE_RE, ""); // verbatim fenced literals are exempt for (const token of RESIDUAL_VOCAB) { - if (text.includes(token)) { + if (stripped.includes(token)) { throw new Error( `untranslated DeepAgents vocabulary "${token}" survived in ${where} — extend TRANSLATIONS`, ); @@ -263,35 +292,6 @@ const SECTIONS = [ { header: "Mode-specific behavior:", target: "disciplines" }, ]; -// Sections whose *write mechanism* differs between OpenWiki and wijzer, so a -// vocabulary swap is not enough — the derived text would tell the agent to do -// something wijzer does with a script instead. These get a documented, -// distribution-method adaptation (body only; the `##
` stays). OpenWiki -// has the agent hand-write an `## OpenWiki` pointer block "every time"; wijzer -// writes a marker-delimited block deterministically with inject-pointer.sh, so -// deriving the verbatim block would both contradict the script and (on update) -// invite duplicate blocks. Keep the parity-relevant rules, replace the mechanism. -const ADAPTED_SECTIONS = { - "Root agent instruction files:": [ - "*Distribution-method adaptation: OpenWiki has the agent hand-write this", - "pointer block; wijzer writes it deterministically with", - "[`scripts/inject-pointer.sh`](../scripts/inject-pointer.sh). The parity intent —", - "a top-level, idempotent pointer into the wiki — is preserved; the exact", - "`## OpenWiki` block OpenWiki embeds here is replaced by the script's", - "marker-delimited block.*", - "", - "- Point coding agents at the wiki from the repository's **top-level**", - " `AGENTS.md` / `CLAUDE.md` — never nested `AGENTS.md`/`CLAUDE.md` files.", - "- Do not hand-write the block. Run `scripts/inject-pointer.sh`, which creates", - " or updates a marker-delimited block idempotently (safe to re-run) and", - " preserves the surrounding content.", - "- On update runs, re-run `scripts/inject-pointer.sh` so a repository that", - " gained an `AGENTS.md`/`CLAUDE.md` since init picks up the block; it no-ops", - " when the block is already present.", - "- Do not make formatting-only edits to these files.", - ].join("\n"), -}; - /** * Split the trimmed system prompt into { intro, sections{header->body} } and * assert the header set is exactly SECTIONS (order included) and that no text is @@ -391,15 +391,16 @@ filesystem to Claude Code's real tools: ${rows} -Two sections need more than a vocabulary swap: +Two more rules beyond the vocabulary swap: - \`OpenWiki CLI reference:\` is **dropped** — its subject, the \`openwiki\` CLI flag surface, is out of wijzer's parity scope, since wijzer's runtime is Claude Code skills (\`/wijzer:init\`, \`:update\`, \`:ask\`), not a CLI. -- \`Root agent instruction files:\` is **adapted** — OpenWiki has the agent - hand-write an \`## OpenWiki\` pointer block; wijzer writes a marker-delimited - block deterministically with \`scripts/inject-pointer.sh\`, so the parity-relevant - rules are kept but the write mechanism and embedded block are replaced.`; +- Fenced \`\`\`code blocks are **preserved verbatim** — not translated and not + residual-vocab-checked. The \`## OpenWiki\` pointer block under "Root agent + instruction files" is the exact literal the agent must reproduce byte-for-byte + into a repository's AGENTS.md/CLAUDE.md, so it keeps OpenWiki's own \`/openwiki\` + path for an interchangeable wiki.`; } function truncate(s, n = 72) { @@ -441,7 +442,7 @@ function buildDisciplines(intro, sections, modeInstructions, sha) { body.push(""); body.push(headerToMarkdown(header)); body.push(""); - body.push(header in ADAPTED_SECTIONS ? ADAPTED_SECTIONS[header] : sections[header]); + body.push(sections[header]); } const translated = translate(body.join("\n")); assertNoResidualVocab(translated, "disciplines.md"); diff --git a/scripts/diff-summary.sh b/scripts/diff-summary.sh deleted file mode 100755 index 4598123..0000000 --- a/scripts/diff-summary.sh +++ /dev/null @@ -1,109 +0,0 @@ -#!/usr/bin/env bash -# diff-summary.sh — summarizes what changed since the last recorded wiki state, so -# the update skill can scope surgical edits (OpenWiki's "≤1-2 pages when <5 files -# changed" discipline). Reads the prior gitHead from openwiki/.last-update.json. -# -# Emits: -# {"stateGitHead":str,"currentHead":str,"commitsSince":int,"changedFiles":int, -# "sourceChanged":bool,"worktreeDirty":bool, -# "commits":[{"sha":str,"subject":str}],"files":[{"status":str,"path":str}]} -# Exit 0 = evaluated, 2 = not a git repo. -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 'diff-summary.sh: unknown argument: %s\n' "$1" >&2; exit 2 ;; - esac -done - -WIKI_DIR="openwiki" -STATE_PATH="openwiki/.last-update.json" -STATE_FILE="$DIR/$STATE_PATH" - -git -C "$DIR" rev-parse --git-dir >/dev/null 2>&1 || { - printf 'diff-summary.sh: %s is not a git repository\n' "$DIR" >&2 - exit 2 -} - -current_head=$(git -C "$DIR" rev-parse HEAD 2>/dev/null || printf '') -state_git_head="" -if [ -f "$STATE_FILE" ]; then - state_git_head=$(sed -n 's/.*"gitHead"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$STATE_FILE" | head -1) -fi - -# worktree dirty (excluding the state file), for the model's awareness -worktree_dirty=false -status=$(git -C "$DIR" status --porcelain --untracked-files=all 2>/dev/null || printf '') -while IFS= read -r line; do - [ -z "$line" ] && continue - p=${line:3}; [ -z "$p" ] && p=$line - p=${p//\\//}; p=${p%"${p##*[![:space:]]}"} - [ "$p" = "$STATE_PATH" ] && continue - worktree_dirty=true - break -done </dev/null; then - range_ok=1 - commits_since=$(git -C "$DIR" rev-list --count "$state_git_head..HEAD" 2>/dev/null || printf '0') - - # commits: %H%s per line - commits_json="[" - first=1 - while IFS= read -r line; do - [ -z "$line" ] && continue - sha=${line%%$'\x1f'*} - subject=${line#*$'\x1f'} - [ "$first" -eq 1 ] || commits_json+="," - commits_json+="{\"sha\":$(json_str "$sha"),\"subject\":$(json_str "$subject")}" - first=0 - done </dev/null || printf '') -EOF - commits_json+="]" - - # files: name-status; take first token as status, last token as path (rename-safe) - files_json="[" - first=1 - while IFS= read -r line; do - [ -z "$line" ] && continue - st=${line%%$'\t'*} - path=${line##*$'\t'} - path=${path//\\//} - changed_files=$((changed_files + 1)) - if [ "$path" != "$WIKI_DIR" ] && [ "${path#"$WIKI_DIR"/}" = "$path" ]; then - source_changed=true - fi - [ "$first" -eq 1 ] || files_json+="," - files_json+="{\"status\":$(json_str "$st"),\"path\":$(json_str "$path")}" - first=0 - done </dev/null || printf '') -EOF - files_json+="]" -fi - -# When we have no usable prior state, commitsSince stays 0 and arrays empty; the -# update skill treats that as "no diff baseline" and falls back to broader review. -if [ "$range_ok" -eq 0 ]; then - commits_since=0 -fi - -printf '{"stateGitHead":%s,"currentHead":%s,"commitsSince":%s,"changedFiles":%s,"sourceChanged":%s,"worktreeDirty":%s,"commits":%s,"files":%s}\n' \ - "$(json_str "$state_git_head")" "$(json_str "$current_head")" "$commits_since" \ - "$changed_files" "$source_changed" "$worktree_dirty" "$commits_json" "$files_json" diff --git a/scripts/inject-pointer.sh b/scripts/inject-pointer.sh deleted file mode 100755 index 465e7e3..0000000 --- a/scripts/inject-pointer.sh +++ /dev/null @@ -1,74 +0,0 @@ -#!/usr/bin/env bash -# inject-pointer.sh — idempotently appends a marker-delimited block to the repo's -# AGENTS.md / CLAUDE.md instructing coding agents to consult openwiki/ (OpenWiki's -# AGENTS.md/CLAUDE.md integration). Safe to re-run: the block is written at most -# once per file; existing user content is preserved. -# -# Emits: {"results":[{"file":str,"action":"created|appended|unchanged"}]} -# Exit 0 on success, 2 on usage error. -set -euo pipefail - -SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) -# shellcheck source=scripts/lib/json.sh -. "$SCRIPT_DIR/lib/json.sh" - -DIR="." -FILES="AGENTS.md,CLAUDE.md" -while [ $# -gt 0 ]; do - case "$1" in - --dir) DIR=$2; shift 2 ;; - --files) FILES=$2; shift 2 ;; - *) printf 'inject-pointer.sh: unknown argument: %s\n' "$1" >&2; exit 2 ;; - esac -done - -BEGIN_MARKER="" -END_MARKER="" - -block() { - cat <> "$target" - action="appended" - fi - else - block > "$target" - action="created" - fi - [ "$first" -eq 1 ] || results+="," - results+="{\"file\":$(json_str "$name"),\"action\":$(json_str "$action")}" - first=0 -done -results+="]" - -printf '{"results":%s}\n' "$results" diff --git a/scripts/inventory.sh b/scripts/inventory.sh deleted file mode 100755 index 24bd5dd..0000000 --- a/scripts/inventory.sh +++ /dev/null @@ -1,106 +0,0 @@ -#!/usr/bin/env bash -# inventory.sh — cheap, bounded repository inventory for the init skill: manifests, -# likely entrypoints, recent git history, a tracked-file sample, and an extension -# histogram. Feeds discovery WITHOUT the model reading the whole tree (OpenWiki's -# "run discipline": discover via manifests/entrypoints, no exhaustive reads). -# -# Emits a single JSON object (see keys below). -# Exit 0 = evaluated, 2 = not a git repo. -set -euo pipefail - -SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) -# shellcheck source=scripts/lib/json.sh -. "$SCRIPT_DIR/lib/json.sh" - -DIR="." -MAX_FILES=200 -while [ $# -gt 0 ]; do - case "$1" in - --dir) DIR=$2; shift 2 ;; - --max-files) MAX_FILES=$2; shift 2 ;; - *) printf 'inventory.sh: unknown argument: %s\n' "$1" >&2; exit 2 ;; - esac -done - -git -C "$DIR" rev-parse --git-dir >/dev/null 2>&1 || { - printf 'inventory.sh: %s is not a git repository\n' "$DIR" >&2 - exit 2 -} - -root=$(git -C "$DIR" rev-parse --show-toplevel 2>/dev/null || printf '%s' "$DIR") - -# tracked files (single source of truth for the rest) -tracked=$(git -C "$DIR" ls-files 2>/dev/null || printf '') -tracked_count=$(printf '%s\n' "$tracked" | grep -c . || true) - -json_array_from_lines() { - # reads newline-delimited items on stdin -> JSON array of strings - local out="[" first=1 item - while IFS= read -r item; do - [ -z "$item" ] && continue - [ "$first" -eq 1 ] || out+="," - out+="$(json_str "$item")" - first=0 - done - out+="]" - printf '%s' "$out" -} - -# --- manifests present at repo root --- -manifest_candidates="package.json pnpm-workspace.yaml turbo.json deno.json \ -pyproject.toml requirements.txt setup.py Pipfile \ -Cargo.toml go.mod pom.xml build.gradle build.gradle.kts settings.gradle \ -Gemfile composer.json Package.swift Project.swift \ -CMakeLists.txt Makefile Dockerfile flake.nix" -manifests_found="" -for m in $manifest_candidates; do - [ -e "$DIR/$m" ] && manifests_found+="$m"$'\n' -done -manifests_json=$(printf '%s' "$manifests_found" | json_array_from_lines) - -# --- likely entrypoints (best effort, from tracked files) --- -# `|| true`: grep exits 1 when a repo has no entrypoint matches; without the -# guard, pipefail + set -e would abort the whole inventory. The array is already -# captured from stdout regardless of the pipeline's exit status. -entrypoints_json=$(printf '%s\n' "$tracked" | grep -Ei \ - '(^|/)(index|main|app|cli|server)\.[a-z]+$|^(src/(index|main)\.[a-z]+)$|^cmd/|^bin/' \ - 2>/dev/null | awk 'NR<=40' | json_array_from_lines || true) - -# --- recent commits --- -commits_json="[" -first=1 -while IFS= read -r line; do - [ -z "$line" ] && continue - sha=${line%%$'\x1f'*} - subject=${line#*$'\x1f'} - [ "$first" -eq 1 ] || commits_json+="," - commits_json+="{\"sha\":$(json_str "$sha"),\"subject\":$(json_str "$subject")}" - first=0 -done </dev/null || printf '') -EOF -commits_json+="]" - -# --- extension histogram (top 10) --- -ext_json="[" -first=1 -while IFS= read -r line; do - [ -z "$line" ] && continue - count=${line%% *} - ext=${line#* } - [ "$first" -eq 1 ] || ext_json+="," - ext_json+="{\"ext\":$(json_str "$ext"),\"count\":$count}" - first=0 -done <..HEAD --name-status --oneline +# else if it has updatedAt but no gitHead: +git log --since "" --name-status --oneline +# else (no prior baseline at all): +git log --max-count=20 --name-status --oneline +``` -**2. Scope the diff.** +Then account for uncommitted local changes: ```bash -"${CLAUDE_PLUGIN_ROOT}/scripts/diff-summary.sh" --dir . +git diff --name-status HEAD +git status --short ``` -Returns `commitsSince`, `changedFiles`, `sourceChanged`, `worktreeDirty`, the -`commits` list, and the `files` (name-status). Build a docs-impact plan from the -changed source: *source change → page affected → edit needed → why.* If a page -can't be tied to a real source/workflow/product/doc change, don't touch it. -Honor the soft budget: `< ~5` changed files → at most 1–2 pages; avoid -`quickstart.md` unless top-level behavior/setup/navigation changed. +Build a docs-impact plan from the changed **source** files: *source change → +page affected → edit needed → why.* If a page can't be tied to a real +source/workflow/product/doc change, don't touch it. Honor the soft budget: fewer +than ~5 changed source files → at most 1–2 pages; avoid `quickstart.md` unless +top-level behavior/setup/navigation changed. Use `git show`/`git blame` on +high-signal changed files to understand *why* they changed. **3. Snapshot before.** @@ -62,8 +82,8 @@ Honor the soft budget: `< ~5` changed files → at most 1–2 pages; avoid Record the `digest` — you'll compare against it to prevent churn. **4a. If `--dry-run`:** stop now. Report the no-op verdict, the diff scope -(commits + changed files), and the specific pages you *would* edit and why. Make -**no** edits and do not run steps 5–7. +(commits + changed files from step 2), and the specific pages you *would* edit +and why. Make **no** edits and do not run steps 5–8. **4b. Otherwise, edit surgically.** Make the minimal accurate edits per your impact plan. Preserve accurate structure and wording; replace stale sentences @@ -81,7 +101,7 @@ Task tool with `subagent_type: wiki-scout`) for an unfamiliar changed domain. Compare to the step-3 `digest`: - **Unchanged** → your edits netted no content change. **Do not write state** and - do not run the pointer step — leave `.last-update.json` untouched so scheduled + do not touch the pointer — leave `.last-update.json` untouched so scheduled runs don't churn a PR. Report "wiki already accurate — no changes". - **Changed** → continue to step 6. @@ -99,12 +119,13 @@ If `ok` is `false`, fix **each** string in `problems` in the affected pages 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. Pointer section (prompt-driven).** Following the **root agent instruction +files** discipline, inspect the top-level `AGENTS.md` / `CLAUDE.md`. Add the +exact `## OpenWiki` section from `references/disciplines.md` if it is missing +(e.g. a repo that gained an `AGENTS.md`/`CLAUDE.md` since init), or refresh it +only if a present one is semantically stale. Do **not** duplicate an existing +section and do **not** make formatting-only edits — no-op when it is already +correct. **8. Record state.** diff --git a/tests/build-disciplines.test.ts b/tests/build-disciplines.test.ts index d0ecf13..fcd7653 100644 --- a/tests/build-disciplines.test.ts +++ b/tests/build-disciplines.test.ts @@ -63,13 +63,27 @@ describe("generated disciplines + wiki-format (drift-locked to prompt.ts)", () = test("no DeepAgents virtual-fs vocabulary survives in either doc body", () => { for (const rel of ["references/disciplines.md", "references/wiki-format.md"]) { - const text = body(read(rel)); + // Fenced blocks are verbatim literals (e.g. the `## OpenWiki` pointer block, + // which keeps OpenWiki's own `/openwiki` path); the generator exempts them, + // so strip them here before checking translated prose. + const text = body(read(rel)).replace(/```[\s\S]*?```/g, ""); for (const token of RESIDUAL_VOCAB) { expect(text, `"${token}" leaked into ${rel}`).not.toContain(token); } } }); + test("the AGENTS.md/CLAUDE.md pointer block is derived verbatim (agent writes it)", () => { + // P2D: no bash injector — the agent reproduces OpenWiki's exact block, so it + // must appear byte-for-byte in the doctrine, including OpenWiki's `/openwiki`. + const d = read("references/disciplines.md"); + expect(d).toContain("## Root agent instruction files"); + expect(d).toContain("This repository has documentation located in the /openwiki directory."); + expect(d).toContain("[OpenWiki quickstart](openwiki/quickstart.md)"); + // And the old inject-pointer.sh adaptation must be gone. + expect(d).not.toContain("inject-pointer.sh"); + }); + test("Claude Code tool vocabulary is present (translation actually fired)", () => { const d = read("references/disciplines.md"); // The virtual tools became real ones. diff --git a/tests/diff-summary.test.ts b/tests/diff-summary.test.ts deleted file mode 100644 index 9036cc5..0000000 --- a/tests/diff-summary.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { writeFile } from "node:fs/promises"; -import path from "node:path"; -import { describe, expect, test } from "vitest"; -import { - createRepoWithOpenWiki, - git, - runScriptJson, - writeLastUpdate, -} from "./helpers/fixtures.ts"; - -type File = { status: string; path: string }; -type DiffSummary = { - stateGitHead: string; - currentHead: string; - commitsSince: number; - changedFiles: number; - sourceChanged: boolean; - worktreeDirty: boolean; - commits: { sha: string; subject: string }[]; - files: File[]; -}; - -const diff = (repo: string) => runScriptJson("diff-summary.sh", repo); - -describe("diff-summary.sh", () => { - test("reports the correct commit range since the last state", async () => { - const repo = await createRepoWithOpenWiki(); - const base = await git(repo, ["rev-parse", "HEAD"]); - await writeLastUpdate(repo, base); - - await writeFile(path.join(repo, "README.md"), "# Test Repo\nA\n", "utf8"); - await git(repo, ["add", "README.md"]); - await git(repo, ["commit", "-m", "change one"]); - await writeFile(path.join(repo, "src.txt"), "code\n", "utf8"); - await git(repo, ["add", "src.txt"]); - await git(repo, ["commit", "-m", "change two"]); - - const d = await diff(repo); - expect(d.stateGitHead).toBe(base); - expect(d.commitsSince).toBe(2); - expect(d.commits.map((c) => c.subject)).toEqual(["change two", "change one"]); - expect(d.changedFiles).toBe(2); - expect(d.sourceChanged).toBe(true); - expect(d.files.map((f) => f.path).sort()).toEqual(["README.md", "src.txt"]); - }); - - test("marks sourceChanged false when only openwiki/ changed", async () => { - const repo = await createRepoWithOpenWiki(); - const base = await git(repo, ["rev-parse", "HEAD"]); - await writeLastUpdate(repo, base); - - await writeFile( - path.join(repo, "openwiki", "quickstart.md"), - "# Quickstart\nrev\n", - "utf8", - ); - await git(repo, ["add", "openwiki/quickstart.md"]); - await git(repo, ["commit", "-m", "docs only"]); - - const d = await diff(repo); - expect(d.changedFiles).toBe(1); - expect(d.sourceChanged).toBe(false); - expect(d.files[0].path).toBe("openwiki/quickstart.md"); - }); - - test("detects an uncommitted worktree change", async () => { - const repo = await createRepoWithOpenWiki(); - const base = await git(repo, ["rev-parse", "HEAD"]); - await writeLastUpdate(repo, base); - await writeFile(path.join(repo, "README.md"), "# Test Repo\ndirty\n", "utf8"); - - const d = await diff(repo); - expect(d.worktreeDirty).toBe(true); - expect(d.commitsSince).toBe(0); - }); - - test("handles no prior state gracefully", async () => { - const repo = await createRepoWithOpenWiki(); - const d = await diff(repo); - expect(d.stateGitHead).toBe(""); - expect(d.commitsSince).toBe(0); - expect(d.commits).toEqual([]); - expect(d.files).toEqual([]); - }); - - test("preserves quotes/unicode in commit subjects (valid JSON)", async () => { - const repo = await createRepoWithOpenWiki(); - const base = await git(repo, ["rev-parse", "HEAD"]); - await writeLastUpdate(repo, base); - await writeFile(path.join(repo, "README.md"), "# Test Repo\nx\n", "utf8"); - await git(repo, ["add", "README.md"]); - await git(repo, ["commit", "-m", 'feat: add "quoted" — café']); - - const d = await diff(repo); - expect(d.commits[0].subject).toBe('feat: add "quoted" — café'); - }); -}); diff --git a/tests/inject.test.ts b/tests/inject.test.ts deleted file mode 100644 index de562a6..0000000 --- a/tests/inject.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { readFile, writeFile } from "node:fs/promises"; -import path from "node:path"; -import { describe, expect, test } from "vitest"; -import { createRepoWithOpenWiki, runScriptJson } from "./helpers/fixtures.ts"; - -type InjectResult = { results: { file: string; action: string }[] }; - -const BEGIN = ""; - -const inject = (repo: string, files: string) => - runScriptJson("inject-pointer.sh", repo, ["--files", files]); - -const countMarkers = (text: string) => - text.split(BEGIN).length - 1; - -describe("inject-pointer.sh", () => { - test("creates the target file when absent", async () => { - const repo = await createRepoWithOpenWiki(); - const res = await inject(repo, "CLAUDE.md"); - expect(res.results[0]).toEqual({ file: "CLAUDE.md", action: "created" }); - - const text = await readFile(path.join(repo, "CLAUDE.md"), "utf8"); - expect(text).toContain(BEGIN); - expect(text).toContain("openwiki/quickstart.md"); - }); - - test("appends to an existing file and preserves user content", async () => { - const repo = await createRepoWithOpenWiki(); - const original = "# My Project\n\nHand-written guidance.\n"; - await writeFile(path.join(repo, "CLAUDE.md"), original, "utf8"); - - const res = await inject(repo, "CLAUDE.md"); - expect(res.results[0].action).toBe("appended"); - - const text = await readFile(path.join(repo, "CLAUDE.md"), "utf8"); - expect(text).toContain("Hand-written guidance."); - expect(text).toContain(BEGIN); - expect(text.indexOf("Hand-written guidance.")).toBeLessThan( - text.indexOf(BEGIN), - ); - }); - - test("is idempotent: a second run does not duplicate the block", async () => { - const repo = await createRepoWithOpenWiki(); - await inject(repo, "CLAUDE.md"); - const second = await inject(repo, "CLAUDE.md"); - expect(second.results[0].action).toBe("unchanged"); - - const text = await readFile(path.join(repo, "CLAUDE.md"), "utf8"); - expect(countMarkers(text)).toBe(1); - }); - - test("handles multiple targets and reports each", async () => { - const repo = await createRepoWithOpenWiki(); - const res = await inject(repo, "AGENTS.md,CLAUDE.md"); - const byFile = Object.fromEntries( - res.results.map((r) => [r.file, r.action]), - ); - expect(byFile["AGENTS.md"]).toBe("created"); - expect(byFile["CLAUDE.md"]).toBe("created"); - }); -}); diff --git a/tests/inventory.test.ts b/tests/inventory.test.ts deleted file mode 100644 index 37604da..0000000 --- a/tests/inventory.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { mkdir, writeFile } from "node:fs/promises"; -import path from "node:path"; -import { describe, expect, test } from "vitest"; -import { createRepoWithOpenWiki, git, runScriptJson } from "./helpers/fixtures.ts"; - -type Inventory = { - root: string; - trackedFileCount: number; - manifests: string[]; - entrypoints: string[]; - recentCommits: { sha: string; subject: string }[]; - topExtensions: { ext: string; count: number }[]; - sampleFiles: string[]; - sampleTruncated: boolean; -}; - -const inv = (repo: string, args: string[] = []) => - runScriptJson("inventory.sh", repo, args); - -describe("inventory.sh", () => { - test("emits valid JSON detecting manifests and entrypoints", async () => { - const repo = await createRepoWithOpenWiki(); - await writeFile(path.join(repo, "package.json"), '{"name":"x"}\n', "utf8"); - await mkdir(path.join(repo, "src")); - await writeFile(path.join(repo, "src", "index.ts"), "export {};\n", "utf8"); - await git(repo, ["add", "."]); - await git(repo, ["commit", "-m", "add source"]); - - const i = await inv(repo); - expect(i.manifests).toContain("package.json"); - expect(i.entrypoints).toContain("src/index.ts"); - expect(i.trackedFileCount).toBeGreaterThan(0); - expect(i.recentCommits[0].subject).toBe("add source"); - expect(i.topExtensions.some((e) => e.ext === ".ts")).toBe(true); - }); - - test("honors --max-files and reports truncation", async () => { - const repo = await createRepoWithOpenWiki(); - const i = await inv(repo, ["--max-files", "1"]); - expect(i.sampleFiles.length).toBe(1); - expect(i.sampleTruncated).toBe(true); - }); -}); diff --git a/tests/plugin-structure.test.ts b/tests/plugin-structure.test.ts index 835f788..dfa295e 100644 --- a/tests/plugin-structure.test.ts +++ b/tests/plugin-structure.test.ts @@ -80,6 +80,31 @@ describe("skills", () => { expect(all.length).toBeGreaterThan(0); }); + test("P2D: the removed value-add scripts stay gone and unreferenced", async () => { + // inventory/inject-pointer/diff-summary had no OpenWiki counterpart; P2D made + // discovery, the pointer, and git inspection prompt-driven. Guard against a + // regression that reintroduces a script or a skill reference to one. + const removed = ["inventory.sh", "inject-pointer.sh", "diff-summary.sh"]; + for (const s of removed) { + expect(existsSync(p("scripts", s)), `scripts/${s} should be removed`).toBe(false); + } + for (const name of SKILLS) { + const md = await read("skills", name, "SKILL.md"); + for (const s of removed) { + expect(md, `skills/${name} still references ${s}`).not.toContain(s); + } + } + }); + + test("init/update discover and write the pointer prompt-driven (no bash injector)", async () => { + for (const name of ["init", "update"] as const) { + const md = await read("skills", name, "SKILL.md"); + expect(md, `${name} should be prompt-driven`).toMatch(/prompt-driven/i); + // The pointer step names the exact ## OpenWiki section, not a script. + expect(md).toContain("## OpenWiki"); + } + }); + test("ask is structurally read-only (no write/edit/bash tools)", async () => { const md = await read("skills", "ask", "SKILL.md"); const { fm } = splitFrontmatter(md);