All notable changes to Writ are documented in this file. The format follows Keep a Changelog, and the project adheres to Semantic Versioning.
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.
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 raisesFullWipeRefusedunless bothWRIT_TEST_GRAPH=1and a non-production(host, port)are in effect, and a refused wipe deletes nothing. The guard lives insideclear_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 overwrit.tomlfor the same reasonWRIT_PORTdoes.- A
critical_errorevent on theerrorsstream (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.
- 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.shplusinstall-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.shabsorbed the global-config patch and the slash-command install;bootstrap.shgained 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,envsubstandcurlare no longer install prerequisites. Python 3.11+ and Docker are the whole list (plusgitfor 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.shandinstall-user-commands.share now thin shims over it and keep their flags, overrides, output shapes and 0/1/2 exit codes, because docs,bootstrap.shand 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 thewrit/package, rejected because every caller runs under bare systempython3before the venv exists (the same constraint that putmemory_capture.pyandgate_advance_outcome.pyinbin/lib/); (c) shimenvsubstitself, 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.jsonis now created instead of failing.patch_settingsreturned 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 awrit-statusline.sh, never clobber a foreign one). - Both bootstraps accept
--preflight: run only the tool-presence and Python-version checks, then exit. It stops beforedocker infoon purpose, so the prerequisite contract is testable without a running Docker daemon, pip, or an ONNX export.
-
Rule injection was disabled for an entire session on a machine without jq.
writ-rag-inject.shextracted the/prompt-bundleerror field with a rawjq -r; the|| trueguard 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 rawjq -rreads in that hook (the four rendered blocks, the bundle error, the/recallbriefing) now go through the jq-firstparsed_fieldhelper, whose default correctly means "no error". -
Gate approval silently advanced nothing on a machine without curl.
auto-approve-gate.shposted/advance-phasewith a rawcurl, and the local_writ_session advance-phasearm 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 newwrit_http_postwrapper inbin/lib/common.sh(curl-first,urllibfallback,WRIT_NO_CURL=1forcing seam mirroringWRIT_NO_JQ), preserving the request byte for byte. -
writ_server_healthreported a live daemon as down whenever curl was absent, which is not daemon-down-equivalent: it made every SessionStart fire a doomed secondwrit serveagainst an already-bound port. It now probes through the wrapper. Same fix class applied torag_query,writ_action_push, both bootstraps' health and/statspolls, and the post-install health poll ininstall-server-service.sh. The remaining raw-curl sites are deliberate and unchanged -- each degrades to the exact branch a stopped daemon produces -- andtests/test_no_tool_prereqs.pycarries 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/tmpshared 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 thenode -e/perl -e/ruby -e/php -requivalents went from silent-allow to a gate decision with an audit row.python -m MODULEstays deliberately unscanned, and the hook says so. -
git worktree adddetection no longer fails open on multi-line commands.shlex.splitdiscards 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_DIRis 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_sessionbehaved differently with and withoutjq. jq's//falls through on null and false but not on an empty string, so an emptyagent_idmade 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_allandexecuteconsume 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.
- 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.
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.
- 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 14bench_targetscontractual targets and all 4 methodology blockers pass. benchmarks/scale_benchmark.pynow writes a "Measurement environment" section intoSCALE_BENCHMARK_RESULTS.mdon 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_idis now universal; Stop, SessionEnd, PreCompact, and PostCompact moved from DOC-ONLY to observed (PostCompact carries the fullcompact_summary); SubagentStart confirmed to carry no task text (17/17 spawns); new tool_response fields recorded (BashpersistedOutputPath/persistedOutputSize, Edit/WritememdirStamped);DirectoryAdded(v2.1.219) added as doc-only. Un-re-measured claims keep explicit old-build tags.
- Hook-system silent-failure defects (full 37-script audit, liveness cross-checked against real-session capture):
writ-rag-inject.shcould silently drop a whole turn's rule injection if any jq extraction failed underset -e(now guarded);session-start-bootstrap.sh's Neo4j probe could hang SessionStart on a black-holed host (nowtimeout 2);validate-rules.shtreated a server-side/analyzeerror as a silent pass (now a visible stderr notice);scripts/stop-server.shfought systemd auto-restart instead of stopping (nowsystemctl --user stopwhen the unit is active). - Force-swap coverage extended:
Plandispatches now governed bywrit-dispatch-disciplinerole routing (workflow-subagentdeliberately exempt); new interpreter force-swap inwrit-bash-write-gate.shrewrites barepytest/python3 -m pytestto.venv/bin/python -m pytestwhen a venv exists, with anadditionalContextdisclosure (verified live on CC 2.1.220). - Destructive benchmarks restored a degraded graph.
_corpus_safety.restore_full_corpusrebuilt frombible/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 tovar/benchmark-graph-snapshot.cypherbefore the first wipe and replays it afterward; the snapshot file doubles as the crash-recovery artifact (writ import-cypher <snapshot>). benchmarks/methodology_bench.pyfailed on import: it still pulledbundle_for/retrieveand the blocker thresholds fromtests/test_methodology_retrieval.pyafter the POL-2 harness extraction moved them totests/fixtures/benchmark_harness.py.- Stale docstrings the rewrite surfaced:
writ doctor"10 checks" (13),writ git-hooks installclaiming a prepare-commit-msg install (retired, strip-only),bootstrap.sh's/tmpdaemon-log banner (fix pending in the banner itself).
- 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.mdreplacesdocs/install-writ.md; a completedocs/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 viamake docs); the marketplace packet atdocs/marketplace/SUBMISSION.mdabsorbs 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.
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 anddocs/reference/.
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.
.claude-plugin/marketplace.jsonwas missing, so the first command in the README's install section (claude plugin marketplace add infinri/Writ) failed withMarketplace 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 layoutclaude plugin install writ@writalready assumed.- All 12 hooks failed to load on a marketplace install.
plugin.jsondeclared"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. Thehookskey is removed;manifest.hooksis for additional hook files only, and Writ has none. claude plugin pathdoes not exist and never has. README.md andscripts/bootstrap-plugin.shwrapped it in$(...), so a copy-paste expanded to the empty string and ranbash /scripts/bootstrap-plugin.sh. All sites now resolve the install directory from theinstallPathfield ofclaude plugin list --json.- Dead references in
README.mdtotemplates/settings.json(removed several releases ago), to.claude/hooks/(nowhooks/scripts/), toscripts/install-harness-config.sh(removed), and toplugin.jsonlifecycle hooks anddefaultEnabledthat the manifest does not declare. Hook counts corrected to 38 scripts over 12 registered events.
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) plusintegration-marked acceptance tests that install from a local-path marketplace into a temporaryHOMEand assert onclaude plugin list --jsonerrors andclaude plugin detailscomponent counts.
claude plugin details writreportsAgents (0)despiteplugin.jsondeclaring five agent files that exist on disk. Plugin agents must live atagents/in the plugin root; Writ's are at.claude/agents/, which Claude Code reads as project agents. Thewrit-*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 inhooks/scripts/writ-dispatch-discipline.sh, so it ships separately. (Resolved in the follow-upa56ca1e: roles moved toagents/, theagentsmanifest key removed entirely, measuredAgents (5)on Claude Code 2.1.220;writ-dispatch-discipline.shneeded no change.)claude plugin validate --strictpasses on manifests with this defect. Manifest validation checks shape, not whether declared components load, so it cannot substitute for a real install.
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).
SKILL.md. Hooks-inventory content is now sourced fromHANDBOOK.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 itscommands,agents, andhooksdeclarations. - The
## Memory tierstable and## Mandatory workflow before any tasksection oftemplates/CLAUDE.md. Workflow rules now arrive via RAG injection, not static template prose. tests/test_version_consistency.py::TestSkillMdVersion(and theskill_md_textfixture).tests/plugin/test_documentation.py::test_skill_md_version_matches_pyprojectand theSKILL_MD = REPO_ROOT / "SKILL.md"constant.- The
- Skill frontmatter in \SKILL.md`bullet fromdocs/plugin-validation.md`.
writ/graph/methodology_ingest.py-- new library module exposingingest_path,ingest_edges,INGESTER_REGISTRY,KNOWN_NODE_TYPES,IngestReport, andIngestError. Both the unified CLI and thescripts/migrate.pyshim 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, withtyper.Exit(code=2)and no Python traceback.writ import-markdown --dry-runflag. 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: TEACHESSKL-PROC-WRIT-FAILURE-001, GATESENF-PROC-PLAN-001, PRECEDESPBK-PROC-TDD-001.bible/methodology/PBK-PROC-ORCHESTRATOR-001.md-- Playbook replacingrules/writ-orchestrator.md. Covers the--orchestratorflag 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 replacingrules/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. ProvidesWRIT_CMD_PREFIXthat prefers.venv/bin/writand falls back topython -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,--onlyfiltering,--dry-run, structured error reporting, edge creation, idempotence, subdirectory scoping, the auto-export scope guard, thescripts/migrate.pyshim 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 slimmedtemplates/CLAUDE.md, the rules-file stubs, and the docs cross-reference updates.
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 viascripts/migrate.py --methodology-dir bible/methodology/. The CLI walks the path recursively, parses each*.md, routes each node throughINGESTER_REGISTRY[node_type], and creates inter-node edges.writ migrateTyper command now calls the new library function in-process instead of shelling out toscripts/migrate.py. Exit-code contract preserved.scripts/migrate.pyreduced from 257 lines to a ~71-line thin shim. Keeps the argparse surface (--bible-dir,--methodology-dir,--dry-run) for backward compat; re-exportsrun_migrationandrun_methodology_migrationfor the existing import contract.rules/writ-workflow.mdreduced from a 41-line failure-mode tutorial to a 5-line stub pointing atSKL-PROC-WRIT-FAILURE-001. Stub retained (not deleted) so the platform's automatic~/.claude/rules/*.mdglobal-load slot continues to surface something.rules/writ-orchestrator.mdreduced from a 65-line orchestrator playbook to a 5-line stub pointing atPBK-PROC-ORCHESTRATOR-001. Stub preserves the--orchestratorflag string and thesuppresskeyword thattests/test_orchestrator_hardening.py:31-48greps for..claude/hooks/writ-rag-inject.shbreadcrumbs at the former lines 220, 585, 641 repointed fromSKILL.mdtoHANDBOOK.md.templates/CLAUDE.mdtrimmed to 17 lines: the## Global preferencesblock 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_fieldflipped polarity: now asserts"skills"is NOT a top-level key on the manifest.tests/test_version_consistency.py::test_all_four_manifests_agreerenamed totest_all_three_manifests_agree; SKILL.md drops out of the comparison set.tests/conftest.py::pytest_sessionfinish,tests/test_retrieval.py::pipeline_dbteardown,tests/test_post_suite_neo4j_restoration.py,tests/test_graph_proximity.py,tests/test_embeddings.py-- shell out towrit import-markdown bible/(was:scripts/migrate.py --methodology-dir bible/methodology)..github/actions/setup-writ/action.yml"Migrate rule corpus" step now invokeswrit 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-- everyscripts/migrate.pyandSKILL.mduser-facing reference repointed atwrit import-markdownandHANDBOOK.md/ Methodology node IDs.- Version
1.3.0->1.5.0acrosspyproject.toml,.claude-plugin/plugin.json,.claude-plugin/marketplace.json(bothmetadata.versionandplugins[0].version).
.claude/hooks/validate-test-file.shTDD-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, exemptstests/paths up front, and anchors^(src|lib|app|writ)/to the repo-relative path so absolute paths whose parents happen to containwritno longer trigger a "production code without a failing test" gate. Regression tests intests/test_phase2_hooks.py::TestValidateTestFileRegexScopingcover both branches.writ import-markdownno longer auto-exports the full graph when invoked against a subdirectory. Pre-fix, runningwrit 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 likebible/methodology/process/rules.md. Auto-export is now gated onpath.resolve() == DEFAULT_BIBLE_DIR.resolve(). Regression tests intests/test_import_markdown_unified.py::TestImportMarkdownEdgeCasescover the subdir-skip and default-root-still-fires cases.tests/test_phase6efg_corpus_promotion.pycorpus counts updated for the new Methodology nodes: 62 -> 66 methodology files, 8 -> 10 Playbook nodes, 8 -> 10 Skill nodes.tests/plugin/test_hooks_routing.pyhook count updated 34 -> 36 (renamed fromtest_hooks_json_covers_all_33_registrationstotest_hooks_json_covers_all_36_registrations).tests/test_ingest.py::TestMigrationIntegrationuses the MERGE-aware unique-rule_idcount instead of the raw parse-call count; the 286-vs-276 disparity was the 10 enforcement-rule IDs that legitimately exist in bothbible/<topic>/rules.mdandbible/methodology/<topic>/rules.md.
- Backwards compat:
python scripts/migrate.py [--bible-dir ...] [--methodology-dir ...] [--dry-run]andfrom scripts.migrate import run_migrationcontinue to work unchanged. The shim prints a deprecation notice to stderr on direct invocation but does not change exit codes.writ migratekeeps its exit-code contract. - Intentional behavior change:
writ import-markdownwith no flags now imports methodology too. Pre-1.5.0 invocations relying on the Rule-only side effect should add--only Ruleto preserve the old scope.
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.
.claude/hooks/writ-mark-pending-test.shregistered onPostToolUseforWrite|Edit. Keys the marker on the parentsession_id(not the worker'sagent_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 onehook_executionfriction event per fire withhook_name=writ-mark-pending-testandfile_pathpopulated..claude/hooks/writ-run-pending-tests.shregistered onStop. Readscache/<session-id>/pending-tests.txt, resolves each entry to a test file viatest_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 undertimeout 60swith output captured tocache/<session-id>/last-test-run.log, capturesemit-summary.pystderr, 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 onehook_executionfriction event withresolved_countandresult_codeso "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 aFAILURES!/ERRORS!headline AND a::in each matched1) ...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 byvalidate-file.shfor linter output (replaces the priorerrors[: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 coverpython-generic,js-ts-generic,go-generic,rust-generic,php-generic, andmagentopatterns; the Magento default invokesvendor/bin/phpunit --cache-directory=/tmp/writ-phpunit-cache --do-not-cache-result -c dev/tests/unit/phpunit.xml.dist <tests>so the.phpunit.result.cachepermission warning is suppressed by default. Projects override via.claude/writ.jsonwithextends_defaultssemantics (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.gitignoreso accumulated session marker / test-run-log directories are no longer tracked.
bin/lib/common.sh:171log_friction_eventdefault-value syntax changed fromextra="${4:-{}}"toextra="${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, theexceptswallowed 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_endhad 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.shrun_groupnow captures the runner's exit code with|| rc=$?instead of a barelocal rc=$?. The bare form executed AFTER a non-zero{ ... } >> "$LOG"redirection, which underset -euo pipefailaborted 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-efor that one statement; everything else in the hook keeps strict-mode semantics..claude/hooks/validate-test-file.shheredoc invocation pattern fixed. The previous form had"$FILE"on a separate line after thePYheredoc terminator, which bash parsed as a separate command (tries to execute the markdown / source file as a shell command, hence thePermission deniederrors) and python3 ran with noargv[1](hence theIndexError: list index out of rangetraceback that was surfacing on every internal plan.md update). Changed topython3 - "$FILE" <<'PY' ... PYso-explicitly directs python3 to read its script from stdin and"$FILE"becomessys.argv[1]as intended. The TDD gate's deny semantics are unchanged..claude/hooks/validate-file.shlinter-summary block at lines 47-67 replaced with a call to the newemit-summary.py. Full linter output now lands incache/<session-id>/<safe-file-name>.lint.json; only the first error reaches stderr along with the log path. Drops theerrors[: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.jsonregister the two new hooks (mark onPostToolUse Write|Edit, run onStop). All three registration surfaces stay byte-for-byte in sync.pyproject.toml,SKILL.md,.claude-plugin/marketplace.json,.claude-plugin/plugin.jsondeclare1.3.0.tests/test_version_consistency.pyEXPECTED_VERSIONbumped accordingly.
- 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 -einteraction inrun_group(above); secondary cause was the PHPUnit warning vs failure conflation inemit-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_idinbin/lib/common.shprefersagent_idoversession_idfor 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 readssession_iddirectly 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 deniedwriting.phpunit.result.cache) under default-permission Magento checkouts. The bundled Magento runner now passes--cache-directory=/tmp/writ-phpunit-cache --do-not-cache-resultso 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 theFQCN::testMethodshape, so warnings are excluded and the surfaced count matches the real number of red tests.
- PHPUnit Allure extension warnings on Magento are environmental and not interpreted as failures by the hook. Magento's bundled
magento/magento-allure-phpunitextension readsallure/allure.config.phpat PHPUnit bootstrap; if absent, PHPUnit emits a warning. The hook surfaces zero failures in that case (noFAILURES!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-extensionsin a project-local.claude/writ.jsonoverride of themagentopattern'srunner_command. - Per-project test-path override. Projects can ship a
.claude/writ.jsonto add custom patterns or replace the defaults entirely. The schema lives inbin/lib/test-paths-defaults.json(same shape, validated bytests/test_test_paths.py). First-match-wins across the merged list; project patterns named after a default replace it;extends_defaults: falsedrops all defaults. - Friction-log telemetry now carries JSON extras for all hooks. As a side effect of the
log_friction_eventquoting fix, every hook that was already passing JSON extras (around twenty callers) now lands the extras in the friction log. Dashboards that previously saw emptyhook_executionentries with nohook_namewill now see the full payload. No schema migration required.
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.
writ-context-watcher.shregistered onUserPromptSubmitandPreToolUsein bothhooks/hooks.json(plugin install path) andtemplates/settings.json(standalone install path). Readstranscript_pathfrom the hook stdin envelope, sumsmessage.usage.input_tokens + cache_read_input_tokens + cache_creation_input_tokensfrom the lasttype=assistantline, divides byWRIT_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 newcontext_warning_emitted_at_pctsession 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-percentHTTP 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/formatHTTP 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.shroutes theformatsubcommand 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 insidevalidate-rules.sh(analysis-status read, context builder, plan-file glob, boundary-mode detect) into one helper invocation that emits a single JSON blob withshould_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 intests/test_hook_perf_floors.pyso a future spawn regression breaks CI rather than dribbling latency back into the hot path silently.context_warning_emitted_at_pctfield added to the session cache schema. Default 0 in the cache initialiser.cmd_reset_after_compactionresets it to 0 so the 50% and 75% bands re-arm after/compact. Forward-compatsetdefaultin_read_cacheso old caches upgrade transparently.
POST /session/{session_id}/modenow routes throughwrit_session._mode_setinstead 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 letWork,WORK, andworkaccumulate 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_changewithchange_type=set, lowercasedfrom_modeandto_mode), same phase-transition audit trail entry.writ-posttool-rag.shorchestrator 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.shformatting now flows throughPOST /session/formatvia the updated_writ_session formatdispatcher inbin/lib/common.sh. The dispatcher renders the HTTP response back into the legacy stdout shape (text body plusWRIT_META:line) so existing consumers are unchanged.writ-pre-write-dispatch.shconsolidates the three sequentialjson.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 withsed -n '<N>p'per line, avoiding additional Python spawns.validate-rules.shreplaces the analysis-status, context-build, plan-file-glob, and boundary-mode inline Python blocks with one call to the newvalidate-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.shfinal exit code is now driven by a per-session sentinel file (${TMPDIR:-/tmp}/writ-validate-rules-invalidated-${SESSION_ID}) instead of an unconditionalexit 2at the tail of the boundary-mode block. When at least one finding is routed toinvalidate-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.pysummary string for the warn-with-zero-violations case now readsNo confirmed violations; N uncertain finding(s) need manual review.instead of the misleading0 potential issues found but unconfirmed.The verdict computation in_compute_verdictis unchanged; downstream callers consuming the distinction still seewarnwithviolation_count == 0, but the summary line no longer composes a literal0into a sentence that reads like a regression.writ-postcompact.shresetscontext_warning_emitted_at_pctto 0 (handled insidecmd_reset_after_compactionso 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.mdadds a "Friction-log schema: themodefield and when it is null" section documenting the four event categories wheremode=nullis legitimate (pre_write_decision,phase_advance,memory_policy_deny,post_compactionwhen 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.jsondeclare1.2.0. Server-startup log emits a warning whenWRIT_CONTEXT_WINDOW_TOKENSis unset, non-numeric, or outside[1000, 10000000]; the watcher hook applies the 200000 default independently so a missing env var does not break boot.
- The cosmetic "0 potential issues found but unconfirmed" PostToolUse banner on no-violation boundary scans, traced to two composing bugs: (1)
validate-rules.sh:412issued an unconditionalexit 2after the boundary-mode block even when no finding had been routed toinvalidate-gate, and (2)writ/analysis/analyzer.py:119composedf"{violation_count} potential issues found..."even whenviolation_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, andto_modefields. The HTTPPOST /session/{session_id}/modeendpoint storedrequest.modeverbatim in the cache, so callers passingWorkorWORKproduced distinct dashboard buckets for the same canonical mode. The endpoint now routes through_mode_setso canonicalization matches the CLI; legitimatemode=nullevent categories are documented inHANDBOOK.mdso 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 forvalidate-rules.sh, mean 226ms / p95 301ms forwrit-pre-write-dispatch.sh. Item 4's spawn consolidation drops the p95 floors to 400ms, 350ms, and 180ms respectively, enforced by the newtests/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 readHTTP fast path.
- 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 setWRIT_CONTEXT_WINDOW_TOKENSto the actual window size so the thresholds fire at the right pct. - The HTTP
POST /session/{session_id}/modeendpoint now returns HTTP 400 for invalid modes. v1.0.0 / v1.1.0 silently stored any string the caller passed, which meantmode: "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
UserPromptSubmitandPreToolUseso 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_TOKENSis 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 to1000000to 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.
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.
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 becausemakeinvoked systempython3which lackedonnxruntime)bench_targets.py::TestRetrievalPrecision::test_mrr5_ambiguous_set(MRR@5 = 0.4886 vs the 0.75 threshold declared inbench_targets.py)bench_targets.py::TestRetrievalPrecision::test_hit_rate_all_queries(0.7576 vs the 0.90 threshold declared inbench_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.
AskUserQuestionadded topermissions.denyintemplates/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-onlybin/diagnostic scripts (check-gates,verify-files,scan-deps,run-analysis,validate-handoff), the read-onlywritCLI subcommands (query,status,role-prompt,validate,analyze-friction,audit-session), the idempotent install scripts underscripts/(bootstrap,bootstrap-plugin,ensure-server,install-harness-config,install-user-commands,stop-server), and thewrit-session.pystate 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.shfor plugin-mode users. Plugin installs render neithertemplates/settings.jsoninto~/.claude/settings.json(the plugin manifest schema has no permissions field;hooks/hooks.jsononly registers hook events) nortemplates/CLAUDE.mdinto~/.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 viaenvsubst '$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-runpreviews the diff for each phase without touching disk. Requiresjqandenvsubst.onnxruntime>=1.20,<2added as a core production dependency inpyproject.toml. The writ runtime importsonnxruntimeinwrit/retrieval/embeddings.pyto serve predictions from the ONNX-exported embedding model. Prior to this declaration, freshpip install -e .runs (including the standalone and plugin bootstrap scripts) silently omitted the package, andbuild_pipeline()silently fell back to SentenceTransformer whenOnnxEmbeddingModel.__init__raisedImportError. With the explicit ONNX contract from commitdae679anow 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 theWRIT_ALLOW_EMBEDDING_FALLBACK=1override.onnxruntimeis wheel-distributed on Linux x86_64 and macOS arm64 for cpython 3.11+;pip installpulls a prebuilt manylinux wheel and does not require local compilation.optimum[onnxruntime]>=2.0,<3added to the[dev]optional-dependencies group. Build-time only:scripts/export_onnx.pyuses optimum to convert thesentence-transformers/all-MiniLM-L6-v2checkpoint to the optimized ONNX graph that the runtime consumes viaonnxruntime. The writ runtime itself never importsoptimum; production installs do not pull it.- New
[fallback]optional-dependencies group declaringsentence-transformers>=3.3,<4. The production runtime no longer importssentence-transformers; it is needed only for theWRIT_ALLOW_EMBEDDING_FALLBACK=1opt-in fallback path inwrit/retrieval/pipeline.pyand for two maintainer-only paths (writ compressand the integrity-check redundancy detection). Pulls thetorch+ 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.
scripts/bootstrap.shandscripts/bootstrap-plugin.shinstall withpip install -e '.[dev]'(was bare-e .) sooptimumis available for the ONNX export step. Both scripts now runscripts/export_onnx.pyafter 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 runpip install -e '.[fallback]'explicitly.writ/retrieval/pipeline.pyfallback branch wraps the inlinefrom sentence_transformers import SentenceTransformerintry / except ImportErrorand raises aRuntimeErrornaming the[fallback]extras install command when the operator has setWRIT_ALLOW_EMBEDDING_FALLBACK=1but the library is missing. Matches the actionable-error shape of the ONNX-unavailable case introduced in commitdae679a. Daemons configured for fallback now fail at startup with a clear remediation message rather than at first request with a bareImportErrortraceback.writ/cli.pywrit compresscommand wraps the inlinesentence_transformersimport. OnImportError, it now exits viatyper.Exit(code=1)with arich-formatted stderr message naming the[fallback]install command. Maintainer-only command; previously raised a bareImportErrortraceback.writ/graph/integrity.py::detect_redundant()replaces the silenttry / except ImportError: return []withraise RuntimeError. The empty-list silent-degradation behavior produced output indistinguishable from "no redundancies found" whensentence-transformerswas not installed, the same bug class fixed for the ONNX silent fallback in commitdae679a. The newRuntimeErrornames the missing library, thepip install -e '.[fallback]'install command, and theskip_redundancy=Trueopt-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 newRuntimeErrorfromdetect_redundant()whenskip_redundancy=False. The conflicts, orphans, stale, and confidence-default checks still run and report; the redundancy outcome is surfaced via a newfindings['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.pywrit validatecommand prints"Redundancy check skipped: <reason>"to stderr whenfindings['redundancy_unavailable']is set, structurally parallel to the existing "Redundant (N):" block. Users who runwrit validateagainst 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_p95now 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 bytest_cold_start. The recordedSCALE_BENCHMARK_RESULTS.mdp95 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.mdupdated with the corrected framing in the Item 2 investigation section.- Finding 9 (hardcoded credential drift, fixed repo-wide): ~20 sites across
scripts/,benchmarks/, andtests/previously declaredNEO4J_URI / NEO4J_USER / NEO4J_PASSWORDas hardcoded literal constants or passed the literals inline toNeo4jConnection(). The codebase's claim ("Neo4j credentials are read fromwrit.toml") only held forwrit/cli.pyandwrit/server.py; everywhere else, credentials were divergent copies. All ~20 sites now read viawrit.config.get_neo4j_uri / get_neo4j_user / get_neo4j_password.tests/test_config_integration.pyextends the existing meta-test (which previously covered three files) with a new parametrizedTestRepoWideNoHardcodedCredsclass that asserts no Python file underwrit/,scripts/,benchmarks/, ortests/contains the canonical default password literal outside a documented allowlist (writ/config.pyitself and the meta-test file). The meta-test now referencesDEFAULT_NEO4J_PASSWORDfromwrit.configrather 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_truthwas skip-marked in commit231ee41because it produced 4 ADJACENT-SWAP divergences between PyTorch and ONNX top-5 rankings when run in isolation, while passing in the fullmake testsuite. 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, nobuild_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 ifsentence-transformersis not installed (it lives in the[fallback]extras group per Finding D). Now runs as part ofmake test, deterministic across 3 sequential isolated runs. templates/settings.README.mdnow documents the standalone-only nature of both the rendered permissions block and the rendered CLAUDE.md, and points plugin-mode users atscripts/patch-global-config.sh.- README "Install as a Claude Code plugin" section now references
scripts/patch-global-config.shso plugin users do not miss the global-config setup (permissions plus CLAUDE.md). SKILL.mdserver-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.mdGetting started section adds a one-line pointer at the plugin install path andpatch-global-config.sh.docs/install-writ.mdrecommendsinstall-harness-config.sh(full install) andpatch-global-config.sh(non-destructive permission/CLAUDE.md update) instead of the previouscpoftemplates/settings.json. The update-path and Known-limitations sections now reflect both options.docs/plugin-validation.mdfresh-install smoke test includes apatch-global-config.shstep plus grep verifications that theAskUserQuestiondeny rule and the Writ-flavoredCLAUDE.mdlanded.docs/SUBMISSION.mdpre-submission checklist now describes the README install steps as "install + bootstrap + patch-global-config" rather than the previous two-line shape.
- Standalone-install users (whose
~/.claude/settings.jsonwas rendered fromtemplates/settings.json) can re-runbash scripts/install-harness-config.shto pick up the new permission entries. The installer is idempotent and backs up before overwriting. - Mutating
writsubcommands (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.tomllistsonnxruntime) and enforced (build_pipeline()raisesRuntimeErrorwhenOnnxEmbeddingModelcannot 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 namingscripts/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 plusonnxruntimeon the production path, or an explicitWRIT_ALLOW_EMBEDDING_FALLBACK=1pluspip install -e '.[fallback]'to permit the fallback. - Behavior change for existing users following the
[fallback]move. Existing standalone installs whose.venvwas built before this change already havesentence-transformersfrom 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: nosentence-transformers, notorch, no CUDA libraries. Users who explicitly relied onWRIT_ALLOW_EMBEDDING_FALLBACK=1and re-bootstrap need to additionally runpip install -e '.[fallback]'to restore the fallback path. The startupRuntimeErrornames this command if hit. - Plugin-mode users re-running
scripts/bootstrap-plugin.shget 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 fromclaude plugin list --jsoninstead.)
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.
.claude-plugin/marketplace.jsondeclaring a same-repo, single-plugin marketplace catalog (name: writ, ownerinfinri, plugin source./)..claude-plugin/plugin.jsonrewritten to conform to the official plugin schema (name,version,description,author,homepage,repository,license,keywords, plus the four component-path fieldsskills/commands/agents/hooks). The previously declaredpermissions,defaultEnabled, andlifecyclefields were never honored by Claude Code and have been dropped.hooks/hooks.jsonplugin 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 launcheswrit servein 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.mddocumenting the two install paths (plugin auto-discovery viahooks/hooks.jsonversus legacy standalone viatemplates/settings.jsonrendered into~/.claude/settings.json).docs/plugin-validation.md, a maintainer reference for validating a Writ release (staticclaude 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.
pyproject.tomlPyPI distribution name renamed fromwrit(taken by an unrelated package) toclaude-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 viapip 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.shlearned dual-mode${CLAUDE_PLUGIN_ROOT}branches. When the env var is set by Claude Code,WRIT_DIRand the venv path resolve against the plugin install; when unset, the originaldirnamewalk runs. Standalone behavior is byte-identical.pyproject.toml,SKILL.mdfrontmatter, and the marketplace/plugin manifests all declare version1.0.1.
templates/settings.jsonis still the source of truth for standalone installs. The plugin path useshooks/hooks.jsoninstead. Keep registrations in sync between the two if you edit either. (No longer true as of a later release:templates/settings.jsonwas removed andhooks/hooks.jsonis 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 volumewrit-neo4j-datais shared between modes, so the rule corpus survives the switch.
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.
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-v2embeddings, 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).
- 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_idsfor prompt-cache stability across turns.
- 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).
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/.
- 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-checkstill emits a[ENF-GATE-FINAL]deny string when the path contains"COMPLETE", whileENF-GATE-FINALitself was removed from the corpus during the 2026-05-10 cleanup. Known drift inwrit/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.