Skip to content

Latest commit

 

History

History
365 lines (259 loc) · 76 KB

File metadata and controls

365 lines (259 loc) · 76 KB

Changelog

All notable changes to Writ are documented in this file. The format follows Keep a Changelog, and the project adheres to Semantic Versioning.

[Unreleased]

[1.7.0] - 2026-08-08

The install collapses to "install the plugin, run one command"; jq, envsubst and curl stop being prerequisites; and the hook layer's own guarantees are audited rather than asserted. Two gates that were failing open now hold, session identity is never guessed, a destructive graph operation needs permission, and the isolation the test suite claimed is enforced instead of assumed.

Added

  • SECURITY.md. States the trust model rather than implying one: Writ runs bash hooks with your privileges, the daemon is unauthenticated with its bind address as the only access control, and the gates are guardrails against an assistant's mistakes, not a sandbox and not a defence against a determined attacker. Includes the reporting channel and a plain statement that auditing what you install remains the user's job.
  • A graph full-wipe guard (writ/graph/db/_safety.py). clear_all(preserve_labels=frozenset()) now raises FullWipeRefused unless both WRIT_TEST_GRAPH=1 and a non-production (host, port) are in effect, and a refused wipe deletes nothing. The guard lives inside clear_all, before the session opens, because a fixture only protects the tests that remember to use it.
  • WRIT_NEO4J_URI / WRIT_NEO4J_USER / WRIT_NEO4J_PASSWORD. Neo4j Community serves one database per instance, so a disposable graph can only be a disposable instance; reaching it needs a per-process override. Env wins over writ.toml for the same reason WRIT_PORT does.
  • A critical_error event on the errors stream (writ_critical, bin/lib/common.sh). Writ previously had no way to record that a hook hit a condition it must not paper over.
  • GET /session/{id}/prompt-state, answering everything the prompt-path hook asks about a session in one call and one cache read.

Changed

  • The plugin install is one script run. It was five things: a marketplace add, a plugin install, a WRIT_DIR discovery one-liner, bootstrap-plugin.sh, and (separately, and easy to miss) patch-global-config.sh plus install-user-commands.sh. Now: claude plugin marketplace add, claude plugin install writ@writ, and the single absolute command the SessionStart hook prints on its own copy-pasteable line. bootstrap-plugin.sh absorbed the global-config patch and the slash-command install; bootstrap.sh gained the command install it never had. The discovery incantation is gone from the docs entirely, because the hook that already detects an un-bootstrapped install already knows the path.
  • jq, envsubst and curl are no longer install prerequisites. Python 3.11+ and Docker are the whole list (plus git for the clone paths). Context: all three existed in the tree only for install-time JSON merges, one-variable string substitution, and HTTP; each already had (or trivially admitted) a Python-stdlib equivalent; and demanding them turned a perfectly capable machine into a failed install. jq and curl remain the fast path when present, never a requirement, and both bootstraps now report them as optional accelerators.
  • Install-time config writing moved from bash-plus-jq-plus-gettext into one stdlib module, bin/lib/writ_install.py (settings, claude-md, hooks, commands, all, http-get, http-post). patch-global-config.sh and install-user-commands.sh are now thin shims over it and keep their flags, overrides, output shapes and 0/1/2 exit codes, because docs, bootstrap.sh and the suite all name them. Alternatives considered: (a) keep the jq programs and simply document the tools as required, rejected because the requirement was the defect; (b) put the module in the writ/ package, rejected because every caller runs under bare system python3 before the venv exists (the same constraint that put memory_capture.py and gate_advance_outcome.py in bin/lib/); (c) shim envsubst itself, rejected as strictly more machinery than substituting two variables in Python. Tradeoff accepted: a JSON merge expressed in Python is more lines than the jq one-liner it replaces, and the merge semantics now live in a second language from the shell that invokes them -- paid for by the merge being unit-testable, by the tests no longer skipping themselves when a tool is absent, and by one fewer thing a user must install.
  • A missing ~/.claude/settings.json is now created instead of failing. patch_settings returned 1 when the file did not exist, which is the common case on a fresh machine and the single largest reason the install needed hand-holding. Parent directories are created; a file that never existed gets no .bak. Everything else is ported one-for-one: append-only allow/deny merge with original ordering, the two-guard stale-entry pruner (a live second checkout survives), the LEGACY_ALLOW subtraction, and the statusLine policy (add when absent, refresh a writ-statusline.sh, never clobber a foreign one).
  • Both bootstraps accept --preflight: run only the tool-presence and Python-version checks, then exit. It stops before docker info on purpose, so the prerequisite contract is testable without a running Docker daemon, pip, or an ONNX export.

Fixed

  • Rule injection was disabled for an entire session on a machine without jq. writ-rag-inject.sh extracted the /prompt-bundle error field with a raw jq -r; the || true guard plus a default-to-failed expansion meant an absent jq produced an empty string that became "1", so a perfectly healthy daemon response was reported as [Writ: query failed, proceeding without rules] -- with a message blaming the server. All six raw jq -r reads in that hook (the four rendered blocks, the bundle error, the /recall briefing) now go through the jq-first parsed_field helper, whose default correctly means "no error".

  • Gate approval silently advanced nothing on a machine without curl. auto-approve-gate.sh posted /advance-phase with a raw curl, and the local _writ_session advance-phase arm is not a usable fallback (it posts {}, dropping the single-use token and the cwd the server needs to resolve the project root). The POST now goes through the new writ_http_post wrapper in bin/lib/common.sh (curl-first, urllib fallback, WRIT_NO_CURL=1 forcing seam mirroring WRIT_NO_JQ), preserving the request byte for byte.

  • writ_server_health reported a live daemon as down whenever curl was absent, which is not daemon-down-equivalent: it made every SessionStart fire a doomed second writ serve against an already-bound port. It now probes through the wrapper. Same fix class applied to rag_query, writ_action_push, both bootstraps' health and /stats polls, and the post-install health poll in install-server-service.sh. The remaining raw-curl sites are deliberate and unchanged -- each degrades to the exact branch a stopped daemon produces -- and tests/test_no_tool_prereqs.py carries them as an explicit allowlist, so a new undocumented raw-curl call fails the suite.

  • Session identity is never synthesized. Hooks used to fall back to a PID-derived or md5(cwd:user) id, and to a single pointer file under /tmp shared by every Claude Code session on the machine. Both produced confidently wrong answers, and state written under them was simply lost. A hook that cannot read an id from its payload now records a critical error and declines to act.

  • Bash-mediated writes through interpreters are gated. python3 -c "open(...,'w')..." and the node -e / perl -e / ruby -e / php -r equivalents went from silent-allow to a gate decision with an audit row. python -m MODULE stays deliberately unscanned, and the hook says so.

  • git worktree add detection no longer fails open on multi-line commands. shlex.split discards newlines, so a command was judged entirely by its first line's verb and a real invocation on any later line was allowed. Detection is now quote-aware, splits on newlines outside quotes, and skips heredoc bodies so a document about worktrees is not mistaken for one.

  • WRIT_CACHE_DIR is honoured by every writer. Pending-test markers and per-file lint logs resolved against the install directory regardless of the variable, so an isolated run still wrote into the live checkout.

  • writ_require_session behaved differently with and without jq. jq's // falls through on null and false but not on an empty string, so an empty agent_id made the jq arm refuse and the Python arm proceed. The seam's contract is that jq changes speed, never behaviour.

  • The dispatch-discipline audit trail named no agent. Every row recorded an empty target, so the record could not say which dispatch it had rerouted, refused, or waved through.

  • clear_all and execute consume their results. A lazy result let a wipe overlap the rebuild that followed it.

  • Corrected several published figures that did not match their own sources. The opening token-cost numbers disagreed with the benchmark file the same README cites and with its own table three sections later; the test-suite counts were stale by 31 modules and roughly 1,400 tests; a monthly-review figure was described as events "in the window" when the source document states the starting count was never captured, so no within-window count exists; and the package description carried a hook count matching neither the README nor the wiring. The injection-cost figures now say plainly that they are the one claim with no shippable artifact behind them, and why.

Security

  • Environment-specific details removed from published documentation and the corpus. Benchmark figures keep their ratios and no longer name the machine they were measured on.

[1.6.0] - 2026-08-01

The trust release: every published number re-measured, the documentation rebuilt from a full code read, the hook system audited end to end with its failure posture made explicit, and the Claude Code contract re-pinned to 2.1.220.

Changed

  • Every published performance number re-measured on 2026-08-01 and reconciled across README, HANDBOOK, docs/reference/, and the marketplace packet: live e2e p95 0.6 ms at the 287-rule corpus; synthetic 10K curve now 0.827 ms p95 with 749x context reduction (was 0.557 ms / 726x from 2026-04-13); retrieval quality republished with exact values (MRR@5 0.5681, hit rate 0.7824, domain-hit 0.9323, nDCG@10 0.7071; methodology MRR@5 0.8271, hit 0.95). All 14 bench_targets contractual targets and all 4 methodology blockers pass.
  • benchmarks/scale_benchmark.py now writes a "Measurement environment" section into SCALE_BENCHMARK_RESULTS.md on every run (host CPU/threads/RAM, the Neo4j container's memory limit or the fact there is none, pagecache size, observed container usage, Python version), so scale numbers are never published without the machine that produced them.
  • The Claude Code black-box map (docs/reference/claude-code-blackbox.md) refreshed from build 2.1.183 to 2.1.220 via live capture filtered to real sessions: prompt_id is now universal; Stop, SessionEnd, PreCompact, and PostCompact moved from DOC-ONLY to observed (PostCompact carries the full compact_summary); SubagentStart confirmed to carry no task text (17/17 spawns); new tool_response fields recorded (Bash persistedOutputPath/persistedOutputSize, Edit/Write memdirStamped); DirectoryAdded (v2.1.219) added as doc-only. Un-re-measured claims keep explicit old-build tags.

Fixed

  • Hook-system silent-failure defects (full 37-script audit, liveness cross-checked against real-session capture): writ-rag-inject.sh could silently drop a whole turn's rule injection if any jq extraction failed under set -e (now guarded); session-start-bootstrap.sh's Neo4j probe could hang SessionStart on a black-holed host (now timeout 2); validate-rules.sh treated a server-side /analyze error as a silent pass (now a visible stderr notice); scripts/stop-server.sh fought systemd auto-restart instead of stopping (now systemctl --user stop when the unit is active).
  • Force-swap coverage extended: Plan dispatches now governed by writ-dispatch-discipline role routing (workflow-subagent deliberately exempt); new interpreter force-swap in writ-bash-write-gate.sh rewrites bare pytest/python3 -m pytest to .venv/bin/python -m pytest when a venv exists, with an additionalContext disclosure (verified live on CC 2.1.220).
  • Destructive benchmarks restored a degraded graph. _corpus_safety.restore_full_corpus rebuilt from bible/ markdown after wiping, which silently dropped all 62 Abstraction nodes (they have no markdown home), lost 2 SubagentRoles, derived a different RELATED_TO edge set, and inherited source-vs-graph flag drift (measured: 400 nodes / 1060 edges / 32 mandatory where the live graph had 464 / 731 / 33). The restore now snapshots the exact live graph to var/benchmark-graph-snapshot.cypher before the first wipe and replays it afterward; the snapshot file doubles as the crash-recovery artifact (writ import-cypher <snapshot>).
  • benchmarks/methodology_bench.py failed on import: it still pulled bundle_for/retrieve and the blocker thresholds from tests/test_methodology_retrieval.py after the POL-2 harness extraction moved them to tests/fixtures/benchmark_harness.py.
  • Stale docstrings the rewrite surfaced: writ doctor "10 checks" (13), writ git-hooks install claiming a prepare-commit-msg install (retired, strip-only), bootstrap.sh's /tmp daemon-log banner (fix pending in the banner itself).

Changed (documentation rebuild, 2026-07-31)

  • Documentation rebuilt from a full code read (all production code line-by-line; the test suite swept at contract level). New structure: README + HANDBOOK rewritten in place; docs/install.md replaces docs/install-writ.md; a complete docs/reference/ set (architecture, graph-schema, retrieval, session-and-gates, configuration, logging, decision-memory, testing, compression, efficacy-ab, the Claude Code black-box map, plus generated cli/http-api/hooks/rulebook pages via make docs); the marketplace packet at docs/marketplace/SUBMISSION.md absorbs PROMOTIONAL-BRIEF.md.
  • Documentation is no longer a test surface: doc-content assertions removed from the suite; generated pages drift-check via make docs-check, not pytest.

Removed

  • docs/extraction/ (all 12 deep dives), docs/ARCHITECTURE.md, docs/LOGGING-BLUEPRINT.md, docs/LOGGING-COVERAGE-AUDIT.md, docs/EFFICACY-AB-RUNBOOK.md, out-of-the-box-rules.md, PROMOTIONAL-BRIEF.md, RESUME.md: superseded by the reference set and ground-truth rewrite; git history is the archive. Each was fully read for unique content first; the survivors (severity rubric, mandatory-selection criteria, and a dozen smaller rationale items) were folded into HANDBOOK and docs/reference/.

[1.5.1] - 2026-07-31

Writ has been published as a plugin since 1.4.x, and the plugin install path had never worked end to end. Three independent breakages, each verified against Claude Code 2.1.220 by installing from a local-path marketplace into a throwaway HOME.

Fixed

  • .claude-plugin/marketplace.json was missing, so the first command in the README's install section (claude plugin marketplace add infinri/Writ) failed with Marketplace file not found. No user could install Writ as a plugin at all. Added as a single-plugin marketplace whose entry sources the repository root ("source": "./"), which is the layout claude plugin install writ@writ already assumed.
  • All 12 hooks failed to load on a marketplace install. plugin.json declared "hooks": "./hooks/hooks.json", which is the path Claude Code auto-discovers. The declaration collided with auto-discovery: Duplicate hooks file detected: ./hooks/hooks.json resolves to already-loaded file .... A marketplace-installed Writ therefore had no write gate, no rule injection, and no enforcement, which is the silent-no-op failure mode Writ exists to prevent. The hooks key is removed; manifest.hooks is for additional hook files only, and Writ has none.
  • claude plugin path does not exist and never has. README.md and scripts/bootstrap-plugin.sh wrapped it in $(...), so a copy-paste expanded to the empty string and ran bash /scripts/bootstrap-plugin.sh. All sites now resolve the install directory from the installPath field of claude plugin list --json.
  • Dead references in README.md to templates/settings.json (removed several releases ago), to .claude/hooks/ (now hooks/scripts/), to scripts/install-harness-config.sh (removed), and to plugin.json lifecycle hooks and defaultEnabled that the manifest does not declare. Hook counts corrected to 38 scripts over 12 registered events.

Added

  • tests/test_plugin_manifest.py. Static invariants (marketplace exists and names the plugin, no declared hooks path resolves to the auto-discovered file, plugin and marketplace versions agree, every declared component path exists) plus integration-marked acceptance tests that install from a local-path marketplace into a temporary HOME and assert on claude plugin list --json errors and claude plugin details component counts.

Known issues

  • claude plugin details writ reports Agents (0) despite plugin.json declaring five agent files that exist on disk. Plugin agents must live at agents/ in the plugin root; Writ's are at .claude/agents/, which Claude Code reads as project agents. The writ-* roles are therefore available when working inside the Writ repository and have never been available to anyone using Writ elsewhere. Fixing it is a coordinated move across the manifest, scripts/ingest_subagent_roles.py, scripts/export_subagent_roles.py, the graph-drift check, and the bare-name steering in hooks/scripts/writ-dispatch-discipline.sh, so it ships separately. (Resolved in the follow-up a56ca1e: roles moved to agents/, the agents manifest key removed entirely, measured Agents (5) on Claude Code 2.1.220; writ-dispatch-discipline.sh needed no change.)
  • claude plugin validate --strict passes on manifests with this defect. Manifest validation checks shape, not whether declared components load, so it cannot substitute for a real install.

[1.5.0] - 2026-05-21

Two coordinated structural changes shipped together. (1) Static workflow content -- the mode-system tutorial, failure-mode rules, orchestrator dispatch playbook, and SKILL.md hook breadcrumbs -- migrates into Methodology nodes that the existing hybrid-RAG pipeline surfaces on demand. The six places that previously described the mode workflow collapse to one canonical source (Neo4j, surfaced via RAG); per-session static token cost drops from ~2000 to ~250 (CLAUDE.md trimmed to user preferences plus a server-down fallback paragraph). (2) writ import-markdown becomes the single canonical entry point for ingesting every node type under bible/ (Rules plus methodology: Skill, Playbook, AntiPattern, Phase, Technique, Rationalization, SubagentRole, WorkedExample, ForbiddenResponse). The duplicate parse-validate-write loop that previously lived in both writ/cli.py::import_markdown (Rule-only) and scripts/migrate.py (methodology-aware) collapses into a single library module, writ/graph/methodology_ingest.py, that both the CLI and the migrate-shim consume (DRY-DUP-002). Per-node-type dispatch becomes a registry lookup instead of an if/elif chain (SOLID-OCP-002); validation failures surface as typed IngestError(file, node_type, node_id, field, reason) records instead of raw Pydantic tracebacks (API-ERROR-002).

Removed

  • SKILL.md. Hooks-inventory content is now sourced from HANDBOOK.md; the mode-system, gate-enforcement, and sub-agent sections move into the four new Methodology nodes listed under Added.
  • The "skills": ["./"] declaration in .claude-plugin/plugin.json. Writ no longer ships as a Claude Code Skill plugin; the plugin remains fully functional via its commands, agents, and hooks declarations.
  • The ## Memory tiers table and ## Mandatory workflow before any task section of templates/CLAUDE.md. Workflow rules now arrive via RAG injection, not static template prose.
  • tests/test_version_consistency.py::TestSkillMdVersion (and the skill_md_text fixture).
  • tests/plugin/test_documentation.py::test_skill_md_version_matches_pyproject and the SKILL_MD = REPO_ROOT / "SKILL.md" constant.
  • The - Skill frontmatter in \SKILL.md`bullet fromdocs/plugin-validation.md`.

Added

  • writ/graph/methodology_ingest.py -- new library module exposing ingest_path, ingest_edges, INGESTER_REGISTRY, KNOWN_NODE_TYPES, IngestReport, and IngestError. Both the unified CLI and the scripts/migrate.py shim delegate here.
  • writ import-markdown --only TYPE[,TYPE,...] flag. Filters ingestion to the named node types; unknown types produce a clean error naming the offending token and listing valid types, with typer.Exit(code=2) and no Python traceback.
  • writ import-markdown --dry-run flag. Parses and validates without writing to Neo4j; reports per-type counts and any validation errors. Composable with --only.
  • IngestReport.render() -- multi-line per-type breakdown plus totals line and edge-creation summary. Powers the CLI's stdout output.
  • bible/methodology/SKL-PROC-MODE-001.md -- Skill node teaching the mode-set workflow. Trigger: new session before the first writable tool call.
  • bible/methodology/PBK-PROC-WORK-WORKFLOW-001.md -- Playbook node for the three-gate Work-mode pipeline (plan -> test skeletons -> implementation). preconditions: [SKL-PROC-MODE-001]. Edges: TEACHES SKL-PROC-WRIT-FAILURE-001, GATES ENF-PROC-PLAN-001, PRECEDES PBK-PROC-TDD-001.
  • bible/methodology/PBK-PROC-ORCHESTRATOR-001.md -- Playbook replacing rules/writ-orchestrator.md. Covers the --orchestrator flag rationale, the four-worker dispatch sequence (explore -> plan -> test -> implement), and the foreground-only execution constraint.
  • bible/methodology/SKL-PROC-WRIT-FAILURE-001.md -- Skill replacing rules/writ-workflow.md. Covers gate-denial handling, /plan UI approval semantics, plan.md timing, and the test-presentation format.
  • tests/_writ_cmd.py -- shared CLI resolver. Provides WRIT_CMD_PREFIX that prefers .venv/bin/writ and falls back to python -m writ.cli. Used by every test that subprocess-invokes the CLI.
  • tests/test_import_markdown_unified.py -- 27 acceptance tests across 7 classes covering default no-flag behavior, --only filtering, --dry-run, structured error reporting, edge creation, idempotence, subdirectory scoping, the auto-export scope guard, the scripts/migrate.py shim contract, and the version bumps.
  • tests/test_methodology_ingest.py -- 14 unit tests for the new library module (registry coverage, IngestError.__str__, IngestReport.render).
  • tests/test_methodology_migration.py -- 26 acceptance tests for the SKILL.md removal, the four new Methodology nodes, the slimmed templates/CLAUDE.md, the rules-file stubs, and the docs cross-reference updates.

Changed

  • writ import-markdown (no flags) now imports every node type under the target directory, not just Rule nodes. Pre-1.5.0 the command was Rule-only; methodology had to be loaded via scripts/migrate.py --methodology-dir bible/methodology/. The CLI walks the path recursively, parses each *.md, routes each node through INGESTER_REGISTRY[node_type], and creates inter-node edges.
  • writ migrate Typer command now calls the new library function in-process instead of shelling out to scripts/migrate.py. Exit-code contract preserved.
  • scripts/migrate.py reduced from 257 lines to a ~71-line thin shim. Keeps the argparse surface (--bible-dir, --methodology-dir, --dry-run) for backward compat; re-exports run_migration and run_methodology_migration for the existing import contract.
  • rules/writ-workflow.md reduced from a 41-line failure-mode tutorial to a 5-line stub pointing at SKL-PROC-WRIT-FAILURE-001. Stub retained (not deleted) so the platform's automatic ~/.claude/rules/*.md global-load slot continues to surface something.
  • rules/writ-orchestrator.md reduced from a 65-line orchestrator playbook to a 5-line stub pointing at PBK-PROC-ORCHESTRATOR-001. Stub preserves the --orchestrator flag string and the suppress keyword that tests/test_orchestrator_hardening.py:31-48 greps for.
  • .claude/hooks/writ-rag-inject.sh breadcrumbs at the former lines 220, 585, 641 repointed from SKILL.md to HANDBOOK.md.
  • templates/CLAUDE.md trimmed to 17 lines: the ## Global preferences block is retained verbatim, a server-down bootstrap-fallback paragraph is added, and a one-paragraph pointer documents that workflow rules now arrive via RAG injection.
  • tests/plugin/test_plugin_manifest.py::test_plugin_json_skills_field flipped polarity: now asserts "skills" is NOT a top-level key on the manifest.
  • tests/test_version_consistency.py::test_all_four_manifests_agree renamed to test_all_three_manifests_agree; SKILL.md drops out of the comparison set.
  • tests/conftest.py::pytest_sessionfinish, tests/test_retrieval.py::pipeline_db teardown, tests/test_post_suite_neo4j_restoration.py, tests/test_graph_proximity.py, tests/test_embeddings.py -- shell out to writ import-markdown bible/ (was: scripts/migrate.py --methodology-dir bible/methodology).
  • .github/actions/setup-writ/action.yml "Migrate rule corpus" step now invokes writ import-markdown bible/. CI gains methodology corpus coverage on the same wall-clock budget.
  • bible/methodology/PBK-AUTHOR-001.md, README.md, docs/extraction/{01,04,08,09}-*.md, benchmarks/bench_targets.py, scripts/instrument-cold-start.py, .claude/CODEBASE.md, .github/workflows/pr.yml -- every scripts/migrate.py and SKILL.md user-facing reference repointed at writ import-markdown and HANDBOOK.md / Methodology node IDs.
  • Version 1.3.0 -> 1.5.0 across pyproject.toml, .claude-plugin/plugin.json, .claude-plugin/marketplace.json (both metadata.version and plugins[0].version).

Fixed

  • .claude/hooks/validate-test-file.sh TDD-gate regex misfired when the absolute path contained /writ/ as an ancestor directory (the skill's own install root). The regex now computes a repo-relative path first, exempts tests/ paths up front, and anchors ^(src|lib|app|writ)/ to the repo-relative path so absolute paths whose parents happen to contain writ no longer trigger a "production code without a failing test" gate. Regression tests in tests/test_phase2_hooks.py::TestValidateTestFileRegexScoping cover both branches.
  • writ import-markdown no longer auto-exports the full graph when invoked against a subdirectory. Pre-fix, running writ import-markdown bible/methodology/ triggered an auto-export whose file-location lookup only scanned within the subdir; rules whose original files lived outside scope fell through to <output_dir>/<domain>/rules.md, creating bogus duplicates like bible/methodology/process/rules.md. Auto-export is now gated on path.resolve() == DEFAULT_BIBLE_DIR.resolve(). Regression tests in tests/test_import_markdown_unified.py::TestImportMarkdownEdgeCases cover the subdir-skip and default-root-still-fires cases.
  • tests/test_phase6efg_corpus_promotion.py corpus counts updated for the new Methodology nodes: 62 -> 66 methodology files, 8 -> 10 Playbook nodes, 8 -> 10 Skill nodes.
  • tests/plugin/test_hooks_routing.py hook count updated 34 -> 36 (renamed from test_hooks_json_covers_all_33_registrations to test_hooks_json_covers_all_36_registrations).
  • tests/test_ingest.py::TestMigrationIntegration uses the MERGE-aware unique-rule_id count instead of the raw parse-call count; the 286-vs-276 disparity was the 10 enforcement-rule IDs that legitimately exist in both bible/<topic>/rules.md and bible/methodology/<topic>/rules.md.

Notes

  • Backwards compat: python scripts/migrate.py [--bible-dir ...] [--methodology-dir ...] [--dry-run] and from scripts.migrate import run_migration continue to work unchanged. The shim prints a deprecation notice to stderr on direct invocation but does not change exit codes. writ migrate keeps its exit-code contract.
  • Intentional behavior change: writ import-markdown with no flags now imports methodology too. Pre-1.5.0 invocations relying on the Rule-only side effect should add --only Rule to preserve the old scope.

[1.3.0] - 2026-05-19

Per-turn test-execution pipeline. PostToolUse mark + Stop-hook runner batch the writes from a single agent turn, resolve source paths to their tests via a config-driven path matcher (generic conventions + Magento layout by default; project override via .claude/writ.json), invoke pytest / go test / phpunit grouped by runner, and surface real failures via the existing emit-summary helper. Silent on pass, terse on fail, per-runner friction telemetry. Path knowledge is no longer hardcoded in the bash hooks: a new bin/lib/test_paths.py helper owns match-src / match-test / resolve-test / runner-for and reads the bundled test-paths-defaults.json plus any project file. Behind that surface this release also repairs a long-standing JSON-quoting bug in log_friction_event (the ${4:-{}} default-value form was appending a stray } to every JSON extras argument, breaking the json.loads for every caller that passed extras) and fixes a set -e interaction in the run hook's run_group that silently aborted the script when phpunit returned non-zero, before the friction event or summary-emission could fire.

Added

  • .claude/hooks/writ-mark-pending-test.sh registered on PostToolUse for Write|Edit. Keys the marker on the parent session_id (not the worker's agent_id), so writes from sub-agents in the orchestrator pattern accumulate in the master session's cache directory where the master's Stop hook will find them. Emits one hook_execution friction event per fire with hook_name=writ-mark-pending-test and file_path populated.
  • .claude/hooks/writ-run-pending-tests.sh registered on Stop. Reads cache/<session-id>/pending-tests.txt, resolves each entry to a test file via test_paths.py resolve-test, groups the resolved tests by runner command (so a turn that touched Python + PHP files invokes pytest once and phpunit once), runs each group under timeout 60s with output captured to cache/<session-id>/last-test-run.log, captures emit-summary.py stderr, exits 0 when the summary is empty (warnings-only runs treated as pass) and 1 with the summary on stderr when real failures are parsed from the log. Always emits one hook_execution friction event with resolved_count and result_code so "did the hook fire?" is answerable from the log even when zero tests resolved or zero failures were found.
  • bin/lib/emit-summary.py. Shared helper that reads a captured runner log and emits a terse stderr summary referencing the log path. Stdlib only. Always exits 0; caller decides hook exit code. Format dispatch: pytest, phpunit, gotest, json. PHPUnit summarizer requires both a FAILURES! / ERRORS! headline AND a :: in each matched 1) ... line, so PHPUnit's numbered warning lists (Allure config missing, result-cache permission denied, etc.) are correctly excluded from the failure count and the first surfaced finding is always a real failing test, not a runner warning. Also used by validate-file.sh for linter output (replaces the prior errors[:5] clip).
  • bin/lib/test_paths.py + bin/lib/test-paths-defaults.json. Config-driven test-path matcher. CLI surface (match-src, match-test, resolve-test, runner-for) consumed by both new hooks. Bundled defaults cover python-generic, js-ts-generic, go-generic, rust-generic, php-generic, and magento patterns; the Magento default invokes vendor/bin/phpunit --cache-directory=/tmp/writ-phpunit-cache --do-not-cache-result -c dev/tests/unit/phpunit.xml.dist <tests> so the .phpunit.result.cache permission warning is suppressed by default. Projects override via .claude/writ.json with extends_defaults semantics (project patterns prepended to defaults, same-named defaults dropped — first-match-wins). Malformed project JSON falls back to defaults silently.
  • tests/test_emit_summary.py, tests/test_log_friction_event.py, tests/test_mark_pending_test_hook.py, tests/test_test_paths.py — 65 new test cases covering the helper's format dispatch, the bash function's JSON-quoting fix, the mark hook's parent-session keying + Magento path-match cases, and the config-driven helper's lookup surface + first-match-wins shadowing.
  • cache/ added to .gitignore so accumulated session marker / test-run-log directories are no longer tracked.

Changed

  • bin/lib/common.sh:171 log_friction_event default-value syntax changed from extra="${4:-{}}" to extra="${4:-"{}"}". The former parsed the second } as the closing brace of the parameter expansion, appending a stray } to every JSON extras argument; json.loads(sys.argv[4]) then raised, the except swallowed it, and every caller (mark hook, run hook, writ-pre-write-dispatch, writ-instructions-loaded, writ-subagent-start, track-failed-writes, others) lost its extras. Quoting the default literal {} repairs the parse without touching any caller. hook_timer_end had been working around this with an inline-Python bypass; the bypass remains in place but is no longer load-bearing.
  • .claude/hooks/writ-run-pending-tests.sh run_group now captures the runner's exit code with || rc=$? instead of a bare local rc=$?. The bare form executed AFTER a non-zero { ... } >> "$LOG" redirection, which under set -euo pipefail aborted the script before the rc was captured, so the friction event and summary-emission below never ran. Putting the redirected group into a || chain suspends -e for that one statement; everything else in the hook keeps strict-mode semantics.
  • .claude/hooks/validate-test-file.sh heredoc invocation pattern fixed. The previous form had "$FILE" on a separate line after the PY heredoc terminator, which bash parsed as a separate command (tries to execute the markdown / source file as a shell command, hence the Permission denied errors) and python3 ran with no argv[1] (hence the IndexError: list index out of range traceback that was surfacing on every internal plan.md update). Changed to python3 - "$FILE" <<'PY' ... PY so - explicitly directs python3 to read its script from stdin and "$FILE" becomes sys.argv[1] as intended. The TDD gate's deny semantics are unchanged.
  • .claude/hooks/validate-file.sh linter-summary block at lines 47-67 replaced with a call to the new emit-summary.py. Full linter output now lands in cache/<session-id>/<safe-file-name>.lint.json; only the first error reaches stderr along with the log path. Drops the errors[:5] clip — if a file has 30 real lint errors, Claude no longer sees only the first five and grinds through five round-trips to discover the rest.
  • hooks/hooks.json, templates/settings.json, and ~/.claude/settings.json register the two new hooks (mark on PostToolUse Write|Edit, run on Stop). All three registration surfaces stay byte-for-byte in sync.
  • pyproject.toml, SKILL.md, .claude-plugin/marketplace.json, .claude-plugin/plugin.json declare 1.3.0. tests/test_version_consistency.py EXPECTED_VERSION bumped accordingly.

Fixed

  • The PostToolUse banner "Stop hook error: Failed with non-blocking status code: No stderr output" the user saw after every successful turn that wrote source files. Root cause was the set -e interaction in run_group (above); secondary cause was the PHPUnit warning vs failure conflation in emit-summary.summarize_phpunit. The combined fix means a turn that writes source files whose tests pass (even with PHPUnit emitting environmental warnings like the Magento Allure / result-cache pair) now ends cleanly with no banner; a turn whose tests genuinely fail surfaces a [ENF-TEST-001] N test failure(s) summary naming the real failing test, not a runner warning.
  • The orchestrator-pattern marker placement bug. detect_session_id in bin/lib/common.sh prefers agent_id over session_id for per-worker cache isolation. The mark hook was calling that helper, so writes from sub-agents (which is where most file writes happen under --orchestrator) landed the marker in the worker's cache directory, where no Stop hook ever read it. Mark hook now reads session_id directly so the marker accumulates under the master orchestrator's session id, where the master's Stop hook (the only Stop that fires at the end of a turn) consumes it.
  • The PHPUnit result-cache permission warning (Failed to open stream: Permission denied writing .phpunit.result.cache) under default-permission Magento checkouts. The bundled Magento runner now passes --cache-directory=/tmp/writ-phpunit-cache --do-not-cache-result so PHPUnit's cache state lives in /tmp (writable by definition) and the result-cache subscriber is disabled (no benefit for our one-test-per-turn invocation pattern).
  • Test-skeleton-phase false-positive count. When test skeletons are written before the implementation (correct TDD red phase), PHPUnit reports the new tests as errors because they call methods that do not exist yet. The summarizer previously counted PHPUnit's numbered warning entries alongside the real test errors (the ^\d+\) .*$ regex matched both); the tightened ^\d+\) .*::.+$ form requires the FQCN::testMethod shape, so warnings are excluded and the surfaced count matches the real number of red tests.

Notes

  • PHPUnit Allure extension warnings on Magento are environmental and not interpreted as failures by the hook. Magento's bundled magento/magento-allure-phpunit extension reads allure/allure.config.php at PHPUnit bootstrap; if absent, PHPUnit emits a warning. The hook surfaces zero failures in that case (no FAILURES! headline, no test-method-shaped numbered entries), exits 0, and writes no banner. Users who want to silence the warning entirely can either supply the missing config file or pass --no-extensions in a project-local .claude/writ.json override of the magento pattern's runner_command.
  • Per-project test-path override. Projects can ship a .claude/writ.json to add custom patterns or replace the defaults entirely. The schema lives in bin/lib/test-paths-defaults.json (same shape, validated by tests/test_test_paths.py). First-match-wins across the merged list; project patterns named after a default replace it; extends_defaults: false drops all defaults.
  • Friction-log telemetry now carries JSON extras for all hooks. As a side effect of the log_friction_event quoting fix, every hook that was already passing JSON extras (around twenty callers) now lands the extras in the friction log. Dashboards that previously saw empty hook_execution entries with no hook_name will now see the full payload. No schema migration required.

[1.2.0] - 2026-05-15

Two themes: workflow observability fixes (Items 2, 3, 4) and proactive context-window management (Item 1). The observability cluster eliminates a misleading PostToolUse banner, repairs case-drift in the friction log so the dashboard groups modes cleanly, and collapses hook-hot-path Python spawns to bring per-write latency floors into a recorded regression-floor test. The context-window item adds a UserPromptSubmit + PreToolUse watcher that emits non-blocking stderr warnings at 50% and 75% of the configured window so the agent can run /compact at a clean stopping point before the FRB-COMMS-002 failure mode (declaring work complete under context pressure without verification) can fire.

Added

  • writ-context-watcher.sh registered on UserPromptSubmit and PreToolUse in both hooks/hooks.json (plugin install path) and templates/settings.json (standalone install path). Reads transcript_path from the hook stdin envelope, sums message.usage.input_tokens + cache_read_input_tokens + cache_creation_input_tokens from the last type=assistant line, divides by WRIT_CONTEXT_WINDOW_TOKENS (env var, default 200000). Emits non-blocking stderr warnings: at 50% ("Performance regressions are at risk past this point. Run /compact at the next natural pause.") and at 75% ("Performance regressions starting. Please come to a stopping point and run /compact to free the window."). Each threshold fires once per crossing, debounced via the new context_warning_emitted_at_pct session cache field; both registrations share the same threshold logic so the 75% warning surfaces mid-task during long PreToolUse chains, not only at the next UserPromptSubmit. Subagent sessions (is_subagent: true) are skipped entirely. Exit code is always 0; the watcher never denies tool calls.
  • POST /session/{session_id}/context-percent HTTP endpoint accepting {"context_percent": int, "context_warning_emitted_at_pct": int}. Replaces the ~50ms subprocess write with a ~3ms HTTP write; the watcher hook writes both fields atomically per turn so the dashboard's context-pressure heatmap stays consistent with the debounce state.
  • POST /session/format HTTP endpoint returning {"text": "<formatted>", "meta": {"rule_ids": [...], "tokens": N}}. Replaces the previous subprocess fallthrough on the writ-posttool-rag.sh hot path. bin/lib/common.sh routes the format subcommand through the new endpoint when the server is reachable; the subprocess CLI stays as fallback for the unreachable-server case.
  • bin/lib/validate-rules-helper.py. Collapses the four sequential Python spawns inside validate-rules.sh (analysis-status read, context builder, plan-file glob, boundary-mode detect) into one helper invocation that emits a single JSON blob with should_proceed, context, phase, plan_file, boundary_mode. Cuts ~150ms off the p95 of validate-rules.sh.
  • tests/test_validate_rules_exit_codes.py, tests/test_analyzer_summary.py, tests/test_server_mode_canonicalization.py, tests/test_validate_rules_helper.py, tests/test_pre_write_dispatch_parsing.py, tests/test_server_format_endpoint.py, tests/test_hook_perf_floors.py, tests/test_context_watcher_compute.py, tests/test_context_watcher_block.py, tests/test_context_watcher_debounce.py, tests/test_context_watcher_messages.py, tests/test_postcompact_resets_context_debounce.py, tests/test_version_consistency.py -- 13 new test files covering the v1.2.0 work. Hook p95 floors live in tests/test_hook_perf_floors.py so a future spawn regression breaks CI rather than dribbling latency back into the hot path silently.
  • context_warning_emitted_at_pct field added to the session cache schema. Default 0 in the cache initialiser. cmd_reset_after_compaction resets it to 0 so the 50% and 75% bands re-arm after /compact. Forward-compat setdefault in _read_cache so old caches upgrade transparently.

Changed

  • POST /session/{session_id}/mode now routes through writ_session._mode_set instead of writing the cache directly. Mode values are lowercased before validation, and invalid modes raise HTTP 400 with a detail naming the four valid modes. The CLI path (writ-session.py mode set) already had this canonicalization; the HTTP path drifted in v1.0.0 and let Work, WORK, and work accumulate as three distinct buckets in the friction log. The endpoint now matches the CLI byte-for-byte: same lowercase normalization, same friction-log emission (mode_change with change_type=set, lowercased from_mode and to_mode), same phase-transition audit trail entry.
  • writ-posttool-rag.sh orchestrator check uses _writ_session read "$SESSION_ID" (curl fast path) plus one Python parse instead of a direct cache-file Python read. Saves ~45ms per write on the PostToolUse hot path when the server is reachable. Fallback path (direct cache read) preserved for the unreachable-server case via _writ_session's built-in fallthrough.
  • writ-posttool-rag.sh formatting now flows through POST /session/format via the updated _writ_session format dispatcher in bin/lib/common.sh. The dispatcher renders the HTTP response back into the legacy stdout shape (text body plus WRIT_META: line) so existing consumers are unchanged.
  • writ-pre-write-dispatch.sh consolidates the three sequential json.load() Python spawns from v1.1.0 into one spawn that emits decision + reason + file_path + payload + hookSpecificOutput JSON + RAG metadata as a single multi-line blob. The shell reads the fields with sed -n '<N>p' per line, avoiding additional Python spawns.
  • validate-rules.sh replaces the analysis-status, context-build, plan-file-glob, and boundary-mode inline Python blocks with one call to the new validate-rules-helper.py. Plan-file detection moved out of the post-verdict path entirely; the helper computes it once and the hook reuses it.
  • validate-rules.sh final exit code is now driven by a per-session sentinel file (${TMPDIR:-/tmp}/writ-validate-rules-invalidated-${SESSION_ID}) instead of an unconditional exit 2 at the tail of the boundary-mode block. When at least one finding is routed to invalidate-gate, the Python block writes the sentinel; the hook checks for it at the top of the script on the next run, exits 2, and removes the sentinel so the run after starts clean. No-violation boundary scans now exit 0, eliminating the cosmetic non-blocking banner ("0 potential issues found but unconfirmed") that appeared in the Claude Code UI on every clean Write/Edit.
  • writ/analysis/analyzer.py summary string for the warn-with-zero-violations case now reads No confirmed violations; N uncertain finding(s) need manual review. instead of the misleading 0 potential issues found but unconfirmed. The verdict computation in _compute_verdict is unchanged; downstream callers consuming the distinction still see warn with violation_count == 0, but the summary line no longer composes a literal 0 into a sentence that reads like a regression.
  • writ-postcompact.sh resets context_warning_emitted_at_pct to 0 (handled inside cmd_reset_after_compaction so the existing PostCompact reset call picks up the change without modifying the shell hook). The 50% and 75% watcher bands re-arm on the next crossing after a compact, matching the v1.0.0 reset-after-compaction pattern.
  • HANDBOOK.md adds a "Friction-log schema: the mode field and when it is null" section documenting the four event categories where mode=null is legitimate (pre_write_decision, phase_advance, memory_policy_deny, post_compaction when no mode has been set yet). The dashboard's null-mode bucket is now a documented distinct category rather than dirty-data to be aggregated away.
  • pyproject.toml, SKILL.md, .claude-plugin/marketplace.json, and .claude-plugin/plugin.json declare 1.2.0. Server-startup log emits a warning when WRIT_CONTEXT_WINDOW_TOKENS is unset, non-numeric, or outside [1000, 10000000]; the watcher hook applies the 200000 default independently so a missing env var does not break boot.

Fixed

  • The cosmetic "0 potential issues found but unconfirmed" PostToolUse banner on no-violation boundary scans, traced to two composing bugs: (1) validate-rules.sh:412 issued an unconditional exit 2 after the boundary-mode block even when no finding had been routed to invalidate-gate, and (2) writ/analysis/analyzer.py:119 composed f"{violation_count} potential issues found..." even when violation_count == 0. The sentinel-driven exit (Item 2a) and the rewritten summary string (Item 2b) close both.
  • Case-drift in the friction log's mode, from_mode, and to_mode fields. The HTTP POST /session/{session_id}/mode endpoint stored request.mode verbatim in the cache, so callers passing Work or WORK produced distinct dashboard buckets for the same canonical mode. The endpoint now routes through _mode_set so canonicalization matches the CLI; legitimate mode=null event categories are documented in HANDBOOK.md so the dashboard surfaces null as a distinct bucket rather than mis-grouped data.
  • Per-write hook latency budget breaches on the hot path. v1.1.0 measured mean 542ms / p95 736ms for writ-posttool-rag.sh, mean 351ms / p95 647ms for validate-rules.sh, mean 226ms / p95 301ms for writ-pre-write-dispatch.sh. Item 4's spawn consolidation drops the p95 floors to 400ms, 350ms, and 180ms respectively, enforced by the new tests/test_hook_perf_floors.py. Each ~50ms saving comes from collapsing a redundant Python spawn or replacing a direct cache-file read with the _writ_session read HTTP fast path.

Notes

  • User-visible behavior change: non-blocking context warnings at 50% and 75%. Tool calls always proceed; the watcher emits red-text stderr warnings when the conversation crosses each threshold so the agent can decide when to come to a stopping point and /compact. Each warning fires once per crossing and re-arms after compaction. Operators on context windows larger than 200000 tokens should set WRIT_CONTEXT_WINDOW_TOKENS to the actual window size so the thresholds fire at the right pct.
  • The HTTP POST /session/{session_id}/mode endpoint now returns HTTP 400 for invalid modes. v1.0.0 / v1.1.0 silently stored any string the caller passed, which meant mode: "Workflow" (a typo) ended up in the cache. Existing callers that posted a valid mode (canonical case or not) continue to work; callers that posted an invalid string now see a 400 with a descriptive detail. Server-side and CLI APIs are otherwise unchanged.
  • The 75% warning fires on both UserPromptSubmit and PreToolUse so it surfaces mid-task during long tool-call chains, not only at the next user prompt. Subagent sessions (is_subagent: true) skip the watcher entirely; workers run with unlimited rule injection and the orchestrator handles its own context budget independently.
  • WRIT_CONTEXT_WINDOW_TOKENS is the only new env var introduced in this release. Defaults to 200000 tokens (the documented context window for individual-plan Claude Code). Team-tier users with the 1M-token window should set it to 1000000 to avoid premature warnings. The server logs a startup warning when the value is unset or outside [1000, 10000000]; the watcher hook applies the 200000 default in either case so the daemon still starts.

[1.1.0] - 2026-05-15

Minor release. Two themes: (1) the gate that was claimed-enforcing in v1.0.0 is now actually enforced and honestly measured, and (2) the install contract drops ~5GB of unused production dependencies after the runtime moved to ONNX-only by default in this release.

Correction to v1.0.0 verification

v1.0.0 reported the contractual benchmark suite as passing. On re-measurement against the live 276-rule corpus during this release cycle (commit 0d7ee3f and the consolidation work that followed), three of those targets fail at their stated thresholds:

  • bench_targets.py::TestColdStartBenchmark::test_cold_start (~25-29s vs the 3.0s budget — the bench had been measuring the SentenceTransformer fallback path because make invoked system python3 which lacked onnxruntime)
  • bench_targets.py::TestRetrievalPrecision::test_mrr5_ambiguous_set (MRR@5 = 0.4886 vs the 0.75 threshold declared in bench_targets.py)
  • bench_targets.py::TestRetrievalPrecision::test_hit_rate_all_queries (0.7576 vs the 0.90 threshold declared in bench_targets.py)

The thresholds in bench_targets.py had drifted from the floors actually applied at release time (the regression floors in tests/test_graph_proximity.py:32-63 had been walked down to 0.45 / 0.75 across the Phase 1-5 public-rulebook expansion; the bench file's MRR5_THRESHOLD=0.75 / HIT_RATE_THRESHOLD=0.90 were the orphan defaults from the 73-rule baseline era). Two files asserting different floors against the same ground truth, neither enforced by CI: the gate existed as a file, not as enforcement.

This release consolidates the two sources into tests/fixtures/regression_floors.py (commit 3bae7b7), pins the bench Makefile to the venv python3 so the production ONNX path is measured (commit 87fea71), recalibrates the cold-start budget to 3.5s against a 10-run measurement on the production path (commit 0d7ee3f), replaces the silent ONNX fallback with an explicit RuntimeError (commit 04de034), adds a hard-blocking PR-checks workflow (commit d43254a), and closes a second silent-fallback site in writ/graph/integrity.py (commit 67753a6).

Anyone reading this entry should take from it the framing that drove the work: regression floors and benchmark thresholds are only meaningful when CI enforces them. A gate that exists in a file but is not run by a workflow does not exist. The CONTRIBUTING.md guidance section added in this release names that invariant directly so future maintainers do not recreate the same drift.

Added

  • AskUserQuestion added to permissions.deny in templates/settings.json. Prevents the agent from opening an upfront clarifying-question wizard before producing a plan. The intended workflow is research first, then a plan with the agent's recommendation called out; the user redirects from the plan rather than answering a tabbed quiz.
  • Cross-mode Writ command allowlist in templates/settings.json. Covers the read-only bin/ diagnostic scripts (check-gates, verify-files, scan-deps, run-analysis, validate-handoff), the read-only writ CLI subcommands (query, status, role-prompt, validate, analyze-friction, audit-session), the idempotent install scripts under scripts/ (bootstrap, bootstrap-plugin, ensure-server, install-harness-config, install-user-commands, stop-server), and the writ-session.py state machine. Patterns use wildcards (*writ/...) so a single entry matches both standalone ($HOME/.claude/skills/writ/...) and plugin (${CLAUDE_PLUGIN_ROOT}/...) command paths.
  • scripts/patch-global-config.sh for plugin-mode users. Plugin installs render neither templates/settings.json into ~/.claude/settings.json (the plugin manifest schema has no permissions field; hooks/hooks.json only registers hook events) nor templates/CLAUDE.md into ~/.claude/CLAUDE.md (the plugin lifecycle does not touch the global instructions file). This script closes both gaps in a single run. The settings step merges the cross-mode allow/deny entries idempotently while preserving the user's existing ordering. The CLAUDE.md step renders the template via envsubst '$HOME', skipping the write when the target already matches the template byte-for-byte. Both steps back up any pre-existing file before overwriting, and --dry-run previews the diff for each phase without touching disk. Requires jq and envsubst.
  • onnxruntime>=1.20,<2 added as a core production dependency in pyproject.toml. The writ runtime imports onnxruntime in writ/retrieval/embeddings.py to serve predictions from the ONNX-exported embedding model. Prior to this declaration, fresh pip install -e . runs (including the standalone and plugin bootstrap scripts) silently omitted the package, and build_pipeline() silently fell back to SentenceTransformer when OnnxEmbeddingModel.__init__ raised ImportError. With the explicit ONNX contract from commit dae679a now in place, the daemon refuses to start when the package is missing, so this declaration is required for a fresh install to produce a working daemon without the WRIT_ALLOW_EMBEDDING_FALLBACK=1 override. onnxruntime is wheel-distributed on Linux x86_64 and macOS arm64 for cpython 3.11+; pip install pulls a prebuilt manylinux wheel and does not require local compilation.
  • optimum[onnxruntime]>=2.0,<3 added to the [dev] optional-dependencies group. Build-time only: scripts/export_onnx.py uses optimum to convert the sentence-transformers/all-MiniLM-L6-v2 checkpoint to the optimized ONNX graph that the runtime consumes via onnxruntime. The writ runtime itself never imports optimum; production installs do not pull it.
  • New [fallback] optional-dependencies group declaring sentence-transformers>=3.3,<4. The production runtime no longer imports sentence-transformers; it is needed only for the WRIT_ALLOW_EMBEDDING_FALLBACK=1 opt-in fallback path in writ/retrieval/pipeline.py and for two maintainer-only paths (writ compress and the integrity-check redundancy detection). Pulls the torch + nvidia CUDA transitive cascade (~5 GB) only when explicitly installed. The three-group partitioning rule established by this change: [dependencies] for production runtime, [dev] for build/test tooling (optimum, pytest, mypy, ruff), [fallback] for the opt-in SentenceTransformer path.

Changed

  • scripts/bootstrap.sh and scripts/bootstrap-plugin.sh install with pip install -e '.[dev]' (was bare -e .) so optimum is available for the ONNX export step. Both scripts now run scripts/export_onnx.py after install, gated on whether the model file is already present at ~/.cache/writ/models/onnx/model.onnx. The skip path prints a one-line note with the model path so re-runs are transparent. End state after bootstrap: the daemon can start immediately on the production ONNX path without a separate manual export step. The [fallback] group is NOT installed by default; operators who want the SentenceTransformer fallback path must run pip install -e '.[fallback]' explicitly.
  • writ/retrieval/pipeline.py fallback branch wraps the inline from sentence_transformers import SentenceTransformer in try / except ImportError and raises a RuntimeError naming the [fallback] extras install command when the operator has set WRIT_ALLOW_EMBEDDING_FALLBACK=1 but the library is missing. Matches the actionable-error shape of the ONNX-unavailable case introduced in commit dae679a. Daemons configured for fallback now fail at startup with a clear remediation message rather than at first request with a bare ImportError traceback.
  • writ/cli.py writ compress command wraps the inline sentence_transformers import. On ImportError, it now exits via typer.Exit(code=1) with a rich-formatted stderr message naming the [fallback] install command. Maintainer-only command; previously raised a bare ImportError traceback.
  • writ/graph/integrity.py::detect_redundant() replaces the silent try / except ImportError: return [] with raise RuntimeError. The empty-list silent-degradation behavior produced output indistinguishable from "no redundancies found" when sentence-transformers was not installed, the same bug class fixed for the ONNX silent fallback in commit dae679a. The new RuntimeError names the missing library, the pip install -e '.[fallback]' install command, and the skip_redundancy=True opt-out flag for callers that intentionally want to exclude this check (the integrity benchmark uses this opt-out, for example).
  • writ/graph/integrity.py::run_all_checks() catches the new RuntimeError from detect_redundant() when skip_redundancy=False. The conflicts, orphans, stale, and confidence-default checks still run and report; the redundancy outcome is surfaced via a new findings['redundancy_unavailable'] key carrying the error message. Degrade-loud-but-continue: a missing optional dep for one of five checks does not kill the integrity scan.
  • writ/cli.py writ validate command prints "Redundancy check skipped: <reason>" to stderr when findings['redundancy_unavailable'] is set, structurally parallel to the existing "Redundant (N):" block. Users who run writ validate against an install without [fallback] now see explicitly that the redundancy check could not run, rather than reading "no redundancies found" as the silent default.
  • benchmarks/bench_targets.py::test_end_to_end_p95 now warms up each query once before the timed loop, then measures 100 timed samples across the 10 queries. Steady-state p95 lands at 0.5-0.6 ms across five sequential runs against the production ONNX corpus, with 17x headroom over the 10 ms budget. Prior behavior (no warmup) measured "first 10 queries after daemon startup" because every iteration-0 query hit cold caches 15-36x slower than warm iterations; the 5 slowest of 100 samples were always iteration-0 outliers and dominated p95 (10-11 ms, intermittently failing the 10 ms budget). The benchmark's purpose has always been steady-state production latency; cold-start is covered separately by test_cold_start. The recorded SCALE_BENCHMARK_RESULTS.md p95 of 0.590 ms was the steady-state number all along; the bench had regressed in what it measured, not in what it observed. SCALE_BENCHMARK_RESULTS.md updated with the corrected framing in the Item 2 investigation section.
  • Finding 9 (hardcoded credential drift, fixed repo-wide): ~20 sites across scripts/, benchmarks/, and tests/ previously declared NEO4J_URI / NEO4J_USER / NEO4J_PASSWORD as hardcoded literal constants or passed the literals inline to Neo4jConnection(). The codebase's claim ("Neo4j credentials are read from writ.toml") only held for writ/cli.py and writ/server.py; everywhere else, credentials were divergent copies. All ~20 sites now read via writ.config.get_neo4j_uri / get_neo4j_user / get_neo4j_password. tests/test_config_integration.py extends the existing meta-test (which previously covered three files) with a new parametrized TestRepoWideNoHardcodedCreds class that asserts no Python file under writ/, scripts/, benchmarks/, or tests/ contains the canonical default password literal outside a documented allowlist (writ/config.py itself and the meta-test file). The meta-test now references DEFAULT_NEO4J_PASSWORD from writ.config rather than duplicating the literal, both for correctness (a future canonical-default change propagates automatically) and to avoid tripping the credential-literal pre-write scanner on the meta-test file itself.
  • Finding 10 (ONNX ranking flake, rewritten and unskipped): tests/test_embeddings.py::TestOnnxRankingStability::test_top5_identical_on_ground_truth was skip-marked in commit 231ee41 because it produced 4 ADJACENT-SWAP divergences between PyTorch and ONNX top-5 rankings when run in isolation, while passing in the full make test suite. The flake was test-order dependent: the assertion relied on whatever corpus state Neo4j happened to be in when the test ran, and adjacent-swap inside top-5 is float32-precision noise that surfaces differently across corpus shapes. The replacement test (test_top1_and_top5_set_equivalent_pt_vs_onnx) declares a fixed inline corpus of 12 rules and 8 queries, embeds both with PyTorch and ONNX, and asserts top-1 strict equality AND top-5 SET equality. No Neo4j, no build_pipeline, no dependency on test execution order. The relaxed top-5-as-set assertion captures the production-meaningful property (the rules surfaced as relevant) without false-positives on adjacent-swap reordering. Skips gracefully if sentence-transformers is not installed (it lives in the [fallback] extras group per Finding D). Now runs as part of make test, deterministic across 3 sequential isolated runs.
  • templates/settings.README.md now documents the standalone-only nature of both the rendered permissions block and the rendered CLAUDE.md, and points plugin-mode users at scripts/patch-global-config.sh.
  • README "Install as a Claude Code plugin" section now references scripts/patch-global-config.sh so plugin users do not miss the global-config setup (permissions plus CLAUDE.md).
  • SKILL.md server-requirements and architecture-reference sections now distinguish the standalone install path (install-harness-config.sh) from the plugin install path (patch-global-config.sh).
  • HANDBOOK.md Getting started section adds a one-line pointer at the plugin install path and patch-global-config.sh.
  • docs/install-writ.md recommends install-harness-config.sh (full install) and patch-global-config.sh (non-destructive permission/CLAUDE.md update) instead of the previous cp of templates/settings.json. The update-path and Known-limitations sections now reflect both options.
  • docs/plugin-validation.md fresh-install smoke test includes a patch-global-config.sh step plus grep verifications that the AskUserQuestion deny rule and the Writ-flavored CLAUDE.md landed.
  • docs/SUBMISSION.md pre-submission checklist now describes the README install steps as "install + bootstrap + patch-global-config" rather than the previous two-line shape.

Notes

  • Standalone-install users (whose ~/.claude/settings.json was rendered from templates/settings.json) can re-run bash scripts/install-harness-config.sh to pick up the new permission entries. The installer is idempotent and backs up before overwriting.
  • Mutating writ subcommands (add, edit, import-markdown, export, compress, migrate, propose, review, feedback, serve) remain gated behind explicit human approval. They are intentionally not in the allowlist; only their idempotent or read-only counterparts are auto-allowed.
  • Combined with the explicit ONNX contract in commit dae679a, the production embedding path is now both declared (pyproject.toml lists onnxruntime) and enforced (build_pipeline() raises RuntimeError when OnnxEmbeddingModel cannot construct and the fallback override is unset). Existing installs that produced silently-degraded daemons will, after upgrade, either start correctly with ONNX or refuse to start with an actionable error message naming scripts/export_onnx.py, the override env var, and the venv install steps. This is desirable behavior, but it is a user-visible change: daemons that used to start by silently switching to the SentenceTransformer fallback will now require either the model file plus onnxruntime on the production path, or an explicit WRIT_ALLOW_EMBEDDING_FALLBACK=1 plus pip install -e '.[fallback]' to permit the fallback.
  • Behavior change for existing users following the [fallback] move. Existing standalone installs whose .venv was built before this change already have sentence-transformers from the prior core-deps declaration; the daemon continues to work without explicit action. Users who recreate their venv (rm -rf .venv && bash scripts/bootstrap.sh) get the lean install: no sentence-transformers, no torch, no CUDA libraries. Users who explicitly relied on WRIT_ALLOW_EMBEDDING_FALLBACK=1 and re-bootstrap need to additionally run pip install -e '.[fallback]' to restore the fallback path. The startup RuntimeError names this command if hit.
  • Plugin-mode users re-running scripts/bootstrap-plugin.sh get the same lean profile after re-bootstrap. Plugin install paths are unaffected for users who do not re-bootstrap. (Command corrected in 1.5.1: this entry originally documented $(claude plugin path writ), a subcommand that has never existed. Resolve the install directory from claude plugin list --json instead.)

[1.0.1] - 2026-05-11

Patch release completing the v1 vision: Writ is now installable as a Claude Code plugin published through a same-repo marketplace. The standalone skill install path at ~/.claude/skills/writ/ is unchanged and continues to work byte-identically to v1.0.0; the plugin path is purely additive.

Added

  • .claude-plugin/marketplace.json declaring a same-repo, single-plugin marketplace catalog (name: writ, owner infinri, plugin source ./).
  • .claude-plugin/plugin.json rewritten to conform to the official plugin schema (name, version, description, author, homepage, repository, license, keywords, plus the four component-path fields skills/commands/agents/hooks). The previously declared permissions, defaultEnabled, and lifecycle fields were never honored by Claude Code and have been dropped.
  • hooks/hooks.json plugin auto-discovery manifest covering all 32 hook event registrations using ${CLAUDE_PLUGIN_ROOT} paths so the plugin can be upgraded without rewiring hooks.
  • hooks/scripts/session-start-bootstrap.sh, a SessionStart probe that detects venv/Neo4j/daemon state on fresh plugin installs, prints actionable setup instructions when prerequisites are missing, and launches writ serve in the background when everything is in place. Always exits 0; never blocks the session.
  • scripts/bootstrap-plugin.sh, the plugin-aware one-time setup script. Creates the venv outside the plugin cache dir so it survives plugin upgrades, brings up Neo4j, ingests the rule bible, and starts the daemon. Idempotent.
  • templates/settings.README.md documenting the two install paths (plugin auto-discovery via hooks/hooks.json versus legacy standalone via templates/settings.json rendered into ~/.claude/settings.json).
  • docs/plugin-validation.md, a maintainer reference for validating a Writ release (static claude plugin validate, pytest skeleton, fresh-install smoke test, rollback procedure).
  • docs/SUBMISSION.md, the Anthropic plugin marketplace submission packet (pre-submission checklist, listing copy, screenshots checklist, submission procedure).
  • .github/workflows/publish.yml, the tag-triggered GitHub Actions workflow that builds sdist + wheel and publishes to PyPI via Trusted Publisher OIDC.

Changed

  • pyproject.toml PyPI distribution name renamed from writ (taken by an unrelated package) to claude-writ. The Python module name (import writ) and the console script (writ) are unchanged. Standard PyPI metadata added: readme, keywords, classifiers, and [project.urls] (Homepage, Repository, Issues, Changelog, Documentation).
  • Plugin-mode venv lives at ${CLAUDE_PLUGIN_DATA:-$HOME/.cache/writ}/.venv, and the package is installed there via pip install -e ${CLAUDE_PLUGIN_ROOT}. Editable installs let plugin upgrades that rewrite the cache dir keep working without a venv rebuild. Standalone installs continue to use ${WRIT_DIR}/.venv.
  • scripts/ensure-server.sh, scripts/stop-server.sh, and .claude/hooks/writ-rag-inject.sh learned dual-mode ${CLAUDE_PLUGIN_ROOT} branches. When the env var is set by Claude Code, WRIT_DIR and the venv path resolve against the plugin install; when unset, the original dirname walk runs. Standalone behavior is byte-identical.
  • pyproject.toml, SKILL.md frontmatter, and the marketplace/plugin manifests all declare version 1.0.1.

Notes

  • templates/settings.json is still the source of truth for standalone installs. The plugin path uses hooks/hooks.json instead. Keep registrations in sync between the two if you edit either. (No longer true as of a later release: templates/settings.json was removed and hooks/hooks.json is now the single hook-registration surface for both install paths.)
  • Existing standalone installs at ~/.claude/skills/writ/ continue working unchanged. See README "Switching from the standalone install to the plugin" if you'd rather move to the plugin path; the Neo4j named volume writ-neo4j-data is shared between modes, so the rule corpus survives the switch.

[1.0.0] - 2026-05-10

First production release. Writ ships as a Claude Code harness with two co-equal layers (hybrid-RAG knowledge service plus session-aware enforcement) over a Neo4j-backed knowledge graph.

Released

Knowledge layer (the librarian). FastAPI service on localhost:8765 running a five-stage hybrid retrieval pipeline:

  • Stage 1: Domain filter (sub-millisecond, post-filter on candidate set).
  • Stage 2: BM25 keyword (Tantivy, in-memory; trigger-field boost 2.0x, body 0.5x).
  • Stage 3: ANN vector (hnswlib over ONNX all-MiniLM-L6-v2 embeddings, LRU cache 1024, HNSW persistence with corpus-hash invalidation).
  • Stage 4: Graph traversal (pre-computed adjacency cache; built once at startup, O(1) lookup).
  • Stage 5: Two-pass ranking (reciprocal-rank fusion + weighted linear combination over BM25, vector, severity, confidence, graph proximity, bundle cohesion; sticky tiebreak for prompt-cache stability).

Live latency at the 276-rule corpus: 0.590 ms p95 end-to-end (17x headroom on the 10 ms budget). Synthetic scale curve holds at 0.557 ms p95 through 10K rules with 726x context reduction vs whole-corpus stuffing.

Enforcement layer (the process keeper). 30 hook scripts under .claude/hooks/, wired via templates/settings.json, plus a 2,090-line session state machine in bin/lib/writ-session.py. Four-mode system (Conversation, Debug, Review, Work) with two Work-mode gates (phase-a plan approval, test-skeletons test approval). Approval requires a one-time token written by the actual user-typed approval path; agent self-approval via raw bash is structurally blocked.

Public rulebook (220 rules across 12 domains). Security, Clean Code, DRY, SOLID, Architecture, Testing, Error Handling, Performance & Caching, Scaling, API Design, Process & Lifecycle, Documentation. Inventory in out-of-the-box-rules.md. Live corpus extends this with Writ-specific rules (ENF-PROC-, FW-M2-, PHP-, PY-, META-*) for a total of 276 rules / 30 mandatory.

Static-analysis backing for mandatory rules. Six cross-language regex analyzers in bin/run-analysis.sh enforce the 19 public-rulebook mandatory rules:

  • analyze_security_injection: SQL injection, XSS, command injection, SSRF, deserialization, CSRF.
  • analyze_security_auth_authz: weak password hashes, non-CSPRNG tokens, mass assignment, missing route auth, unvalidated request body, file-upload sinks.
  • analyze_security_crypto_headers: hardcoded secrets (Stripe, AWS, GitHub, PEM), AES ECB, weak RNG in crypto context, verify=False.
  • analyze_security_data_protection: PII identifiers in logger calls.
  • analyze_performance_n_plus_one: loop-body DB calls with the loop variable.
  • analyze_scaling_stateless: module-level user/session globals.

AI rule proposal with structural gate. POST /propose runs five checks (schema validation, mechanical-enforcement requirement for mandatory rules, specificity against a 10-pattern vague-language blocklist, redundancy/novelty thresholds at 0.95/0.85 cosine, conflict detection). Accepted rules enter as authority: ai-provisional, confidence: speculative.

Frequency-driven graduation. Stop-hook auto-feedback correlates loaded rules with static-analysis pass/fail and posts to /feedback. Graduation at n=50 and ratio>=0.75 substitutes the empirical ratio for the static confidence weight at query time.

Sub-agent isolation. Two flags compose cleanly: is_subagent (set on SubagentStart, bypasses gates and budget skips) and is_orchestrator (set on mode set work --orchestrator, suppresses broad RAG injection on the master and emits a compact status line instead).

Architecture invariants

  • Pre-computation philosophy. Tantivy index, hnswlib index, ONNX model, adjacency cache, and abstraction summaries are all built at startup from Neo4j and served from memory. Nothing is computed at query time that could have been computed earlier.
  • Mandatory-vs-retrieved structural split. Mandatory rules are excluded from BM25 and the vector store at index-build time. They are loaded out-of-band by hooks with their own 5,000-token budget cap. No change to ranking weights, embedding model, or graph traversal can cause a mandatory rule to disappear from agent context.
  • Authority preference. Human-authored rules outrank AI-authored rules at equal relevance (hard rerank within a configurable score band).
  • Sticky rule ordering. Within a 0.02 score band, the ranker stabilizes injection order from last_injected_rule_ids for prompt-cache stability across turns.

Verification (cited from the v1 release commit)

  • 1,441 tests pass, 15 skipped, 0 failed.
  • 12 contractual benchmark targets pass.
  • Live service: 276 rules, 30 mandatory, index state warm.
  • MRR@5 (ambiguous, n=19): 0.4886 (floor 0.45).
  • Hit rate (Phase 6 ground-truth corpus, n=165): 0.7636 (floor 0.75).
  • Methodology MRR@5 (n=40): 0.8583 (unchanged from Phase-0 baseline).

Documentation

README.md, HANDBOOK.md, PROMOTIONAL-BRIEF.md, SCALE_BENCHMARK_RESULTS.md, SKILL.md, CONTRIBUTING.md, plus out-of-the-box-rules.md for the public rule inventory. Detailed codebase deep-dives in docs/extraction/01 through docs/extraction/12. Install instructions in docs/install-writ.md. Monthly review template in docs/monthly-reviews/TEMPLATE.md. Historical pressure-run records preserved in docs/pressure-runs/.

Known limitations

  • Retrieval-quality floors were lowered during the Phase 1-5 public-rulebook expansion (MRR@5 from 0.78 to 0.45, hit-rate from 0.90 to 0.75) as the ambiguous-evaluation set held constant at 19 queries while the corpus grew 3.8x. The Phase 6 plan in the roadmap is to regenerate the ground-truth corpus and raise the floors back up.
  • POST /pre-write-check still emits a [ENF-GATE-FINAL] deny string when the path contains "COMPLETE", while ENF-GATE-FINAL itself was removed from the corpus during the 2026-05-10 cleanup. Known drift in writ/server.py:1075-1083, not load-bearing.
  • The synthetic scale benchmark restores only Rule nodes; do not regenerate the curve without first re-exporting and re-importing the methodology corpus.