diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 57083a6..12c9fc6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,7 @@ jobs: tests.test_wo_capability_wall tests.test_check_work_order_dispatch tests.test_init_sh + tests.test_uninstall_writwall - name: Full standard-library suite if: ${{ matrix.python-version != '3.10' }} run: python -B -m unittest discover -s tests diff --git a/ADOPTING.md b/ADOPTING.md index b93713d..ef1896d 100644 --- a/ADOPTING.md +++ b/ADOPTING.md @@ -1,3 +1,5 @@ +> **Final recovery release: v0.13.0.** New adoption is discouraged. Existing users can leave Writwall without a work order or Doctrine migration. See [emergency uninstall](docs/uninstall.md) and [what failed](docs/recovery-postmortem.md). The earlier adoption guidance below is retained for reference. + # Adopting Writwall Writwall is a document-controlled governance methodology with a self-hosting reference implementation and project-scaffolding toolkit. This is the complete on-ramp. If you do not yet know which agent to open, where it should run, or what to say first, begin with [`START-HERE.md`](START-HERE.md); it requires no prior Doctrine knowledge. @@ -83,7 +85,7 @@ accepted compact `--brief` continuation and classified `--external-operator-task` operational preflight. ```text -python -m pip install "https://github.com/HLLMR/writwall/archive/refs/tags/v0.12.0.zip" +python -m pip install "https://github.com/HLLMR/writwall/archive/refs/tags/v0.13.0.zip" # Installed command writwall start --project-root /path/to/your-project diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1ccbc71..daf53f3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -78,7 +78,7 @@ gate against the final checked public candidate on native Windows and native Ubuntu: ```text -python checks/check_coordinator_release.py --expected-tag v0.12.0 +python checks/check_coordinator_release.py --expected-tag v0.13.0 ``` The gate copies the candidate to temporary build space, builds and installs the diff --git a/PROJECTION-MANIFEST.sha256 b/PROJECTION-MANIFEST.sha256 index 6993a4e..47f147f 100644 --- a/PROJECTION-MANIFEST.sha256 +++ b/PROJECTION-MANIFEST.sha256 @@ -3,11 +3,11 @@ ad5c9e60c3e8512adbbe005585ab801cb58b44cb277ad2136fc0c2c42c5ad480 .github/ISSUE_ 97dd39f9b48f5eba205125b007204e6241088ad7a4b4e606132107f4b327f41a .github/ISSUE_TEMPLATE/feature_request.yml b2e36dfcfc6eb31570c9340640bcd73abc62f57c80b4794abf36e3d3e89ab34f .github/dependabot.yml 9e90d43615b02a265b08692ef7a1c00a37477a5c6b1e8233a8fc7bedefaedea6 .github/pull_request_template.md -3c35b31bc2b80d101a3a549da68fec55587b6a6428ce6b32112670276490ce23 .github/workflows/ci.yml +361686a3e77ea024ad83df04f4291dfc660743bf8928fce48ccec0588dfdccb7 .github/workflows/ci.yml e544abe8ffd83c81c7b002cbd2e552f9d56f226ea20e1e0722c5d1bdec914fe0 .gitignore -e560e8a944fb8df31f7088cb9023dd54fd9067801b9a2f2474f7c9d71a87e00e ADOPTING.md +313c7a124add4bb26a899b3d294372fd13a085b4ed9de96cda7ca4fb7af4f5c6 ADOPTING.md 1179c999034f4ec1c1d44c1946bd2955c4625905e80767abe760c8c3ab01c493 CLAUDE.md -01cc42aa109377ce00414f95d54725d6336e395302d7287b5502e72eab6942b0 CONTRIBUTING.md +7e275a3eb8c1a2e8bdcee94114eeedf73c6659bfccb0df6c3d792b662b699a09 CONTRIBUTING.md 6d66b658ea58a3ccf94ebd4194243cbc816fa350b3c0f95d0fa85590ef3de1ae DOCTRINE.md 9ba9550ad48438d0836ddab3da480b3b69ffa0aac7b7878b5a0039e7ab429411 LICENSE a38775f2d68b40577253ee48061ba67af3c75b7620dc506b34b40ce2a3b660ee LICENSE-MAP.md @@ -16,17 +16,17 @@ c274f80372d90c012937370f0e1f15087d22e308ef98b27cea5dc0d2d088366c LICENSES/Apach a2010f343487d3f7618affe54f789f5487602331c0a8d03f49e9a7c547cf0499 LICENSES/CC0-1.0.txt 59746d6285ffa44bfc7ecada352aa5d6a20dc8eab418a60ce091cc739012c135 LICENSES/MIT-0.txt 35e6d37b7c5fa0c1fc872315cbd362cd24bfa41e1b7dc3019fbcd31e99350f51 NAMING.md -19c771b511ce6802405b2c8581f0c62cc319f4d6255da372d9b7f574e43f9d8c PROJECTION-PROVENANCE.md -655ebe242bd67cfe5891e390980ff8f4404d84d2d10e096aba23d11e54c6f0bc PUBLICATION.md -4f49c016aba20e207a72f9e19c6a7057954e87c753f8208e31bce6c13b39f9aa README.md -284a0862f3be77e8d867aa4d3ef92ed1a64ad4315d6d9074f6bb64771b6d1dd0 REUSE.toml +aaec1bebdd461d7d88dfc2fe10c89844d81fc1bee8c9ab207bc14383b2c77766 PROJECTION-PROVENANCE.md +9b63c8d89fec8469642c1bc4508ef094a8392eff7fea22f924b4d37bcd81dbde PUBLICATION.md +d6275f7dcf3859ad26250101893686d741332ac1a05b3eaa43d3e8a4bc944578 README.md +d54cb8a50330604bb1396df60da1ec153e3e9f6c94f3139d9c2f9685ed59d35a REUSE.toml ab75b39490b4db4e203f5b23b480a1c998d87cf07d760cb787cb260778b21d0a SECURITY.md ca53dba00262f47351796b07ca236926b517e5a19ac3667df9a1045dcc4dbccf SELF-HOSTING.md -4e59ad3907148bef7e790eb0ab306f1908c9f9ba0362785f1838503ba826e596 START-HERE.md +bdc0cf6c80f021c5172c80190bb49cddd37a0a33ebb59dc4ce868c023be6816f START-HERE.md 75c7ae0f569148f489570d63df916a70b6ccf24b77db2076cdee29663f747428 adapters/claude-code/README.md aeb7f81d139e7ffa6de9a1782444b99eb6d549ac1c3a8b9c0f8bcb9c6addfa6d adapters/claude-code/SECURITY.md dd29af2a39d25e0270ad9acc23ee912f81e39c674e1179759f4a3010c6a0c1a0 adapters/claude-code/wo_capability_wall.py -2ffc8e711d3b3006a8b9490890727c16c38a97e184d16d8a8cfbe84c1dbfcbe8 checks/check_coordinator_release.py +be8f52520f230a243242c03269c0f0cd677fb55fa3505da472be209a9a0b0667 checks/check_coordinator_release.py 0c36b90c28084c3bcf808298bec54f02c50117d3307e307bf793ff61d8284f8f checks/check_distribution.py 60fe377dac32b8d1697f859371ef40d26ed4e695fdceeda6d29ec0318d539504 checks/check_identity.py 30986c40ff7c9b29e2fba39ec04c18af3c1c351490410bd532a8391bcb92ed11 checks/check_licenses.py @@ -53,6 +53,8 @@ ad0fb4f671b8da9e3ab9720af7b39ac9c93201e6131c1df996e090a2bb2acc8a docs/assets/wr e1214e3e6018642809339249bb091a6fd754847b4f77c4bc7a39c5c87e6769cc docs/identity-migration.md b664a305cea2ea7de364df3aa05b9644c334c6ded7e4e0771ae11a512205d4d8 docs/name-clearance.md 1eef400dd2e12b109ceb9b30c107dbd7f25d3c64182346dd0f370bf9ccaa7088 docs/privacy-screen.md +c1f047478fc33f74c58a031729cffb435d59a4108d766727177edbf89401ebf4 docs/recovery-postmortem.md +4bb2862e77b05fc2eda3bf05c319c9b8ea88924a3e7c37f3b0752090ba5783c6 docs/uninstall.md 55716ad256ad76dd355d10ab872ffcd29b03fbf9651763b21351e813ef89d0c5 examples/README.md f5c83009bcb248eccc8e861b5fc52b208a193c0b02293c72c0f4e96fcf3072de examples/name-clearance-incident-2026-08.md dbab45d15702d32ea745076b0dee05c293346b4f42581fd2cb869deb1c5b51b3 examples/name-clearance-ledgers/grantcord-candidate.json @@ -62,11 +64,11 @@ d2c5a8ca21edf842dfd17a83862024afa0a92349abf693a60e55ce454c8d78fa examples/name- 0d62666ddc07a4283309bcbc8f9501add4ecec052d26369d30ce232e17c17702 examples/plumbline-self-hosting-pilot.md 30cdb11fbeb2fd9bbf4048255331ccbbdd6e516fae5adfa5317af00ce53607c9 governance/ADOPTION-MAPPING.md 540f8a7ef20356ff85a673d9a238d1b8e3a575e5571f69a04a9f293a78466b91 governance/LOG-denials-probes.md -a410ee460216b818482ecf1df9583a8a0cf17542b92fcaf1a3503f21cdfcc64c governance/LOG-denials.jsonl -ba4bd0d79505aa32782b41cf3143f27d2ed0509c2125482502b3737b9aeb754c governance/LOG.md -a6036b492d0f3aca7c5bd455ccc4314f3d67d1d407936f6c6e17c55e4a39f250 governance/PLAN.md +6607f59118bd4d6cdfe2f395e56417521cd5fd733155406534a62563551ea69f governance/LOG-denials.jsonl +4bcc8210fc26b67a3365202ce222f9763fc322b24cfad9d4084857ae8914b5ba governance/LOG.md +cb2ea71cc5d7e7b1a62dcbb54f3677d4e9ce3a2c66fea63f9fb818dfd57dda73 governance/PLAN.md 2c71b1468e99fa29abb310ba323f7cd0f0a21447bb03d97aada683be0360ba0a governance/ROUTING.md -7421478c01f4e343c3d255b3daa22e9cb3ce232915ab87c1e490f6be1e79fea3 governance/STATE.md +6198ae29bf1006dac6f1cd44f50abae22511e929ef53eb3db77681f195fd99c1 governance/STATE.md e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 governance/archive/.gitkeep e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 governance/briefs/.gitkeep e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 governance/decisions/.gitkeep @@ -83,19 +85,20 @@ b567ce0c0867464328e81774d888f6491fa66b68ac73be01f993e5c4c66d3ed8 governance/tem d355e46f978f17de8824af805e05124e0f20b1c072523b518422044f88c6f079 governance/templates/D-adoption-record.md 2b586efadab716a59fcafb74312a45a05401a4787fee6ae18cb5c9dd14ef3a09 governance/templates/E-adoption-mapping.md e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 governance/work-orders/.gitkeep -b1622ace4bb600a17997bebbb6943be25cd597eec4f6622fef07b0ef2eb3b138 identity/legacy-references.json +25936eb7d43886e7853bc09a50e450d7fe73084f4a2ae2ac3b07d9c9f23839df identity/legacy-references.json 345b7e962731c085a95aea66a344eae000b27c9bde13b0d790c76b73273dbe7a init.sh 5c90584642f405534b2071f27396ff293ab01632e4dbb8ccb6b8ec043dca4cc9 migration-guides/0.1-to-0.6.md ba4eff258ca5b9a45f3f9f1cbf646ba5bc5521fae812adacac65cd2e78698c9d migration-guides/0.6-to-0.7.md 7be9ff49c33830f929584e9e6756f06be8634b1c79ff7210bf5184b51bfc0769 migration-guides/0.7-to-0.8.md 33d73dd0a32788d673df486b3dcf301ed03edd05c8f8d7920687e0f77da97d49 migration-guides/0.8-to-0.9.md -28ba77c4dfd9370bfe9fca2d349cd042c9a55ac8c6f1c09efca70e73a86d278f projection/public-files.txt -0dfedba0dae8b67ea395cfe30d81c5571516133cae1d6aae59dfa4fd060378b3 pyproject.toml +c216ab40e0a7e6c4d38e1e225c8eedec57f361418e4bcbcf974ce8d02bd804b7 projection/public-files.txt +b97fb33be1927178081a641156c4f9707c0e346969c55865e50bee6bc603eafc pyproject.toml 455ca1ab3c9e7e78afbb9946e13b94497ba24003ab6f411ea96cce26d4ecc39e scripts/build_distribution.py df3573c418fda6fbd048f79f7845451b0fe4d394f0d47ccc7ca8f236b2fdac7f scripts/build_public_projection.py 3cf88f936599e0e84bc2368bc0503a39b9f96e47b473e09b26569e5c3c9edbd9 scripts/collect_name_clearance.py c372f7f1736eb77bedaca43696ea0b060333733f912053479b363442022c4b24 scripts/privacy_screen.py -75d6274cb4f8cec18d8712ac66e5da4da00cfd8cb93089d7f19187ca39906fa8 scripts/start_writwall.py +ee0e81a204404f0bab16997fadbe759a8792a86104ceb74121ffdb8ed32a5db0 scripts/start_writwall.py +2023a59943734af97b89bc082156a90ea245d0a1179123719a8d715fa1c387e5 scripts/uninstall_writwall.py 374f4e8a80b7b9e162b9360a3907b6ffe12ce94ba0ed827058c7b3c9c0658b2c skills/writwall-adopt/LICENSE-MAP.md 84eb284d1972c55f5901b315bdeb7a179c71145817bbff09d83d5befdce515ae skills/writwall-adopt/SKILL.md 75c7ae0f569148f489570d63df916a70b6ccf24b77db2076cdee29663f747428 skills/writwall-adopt/assets/adapters/claude-code/README.md @@ -124,15 +127,16 @@ b70d788604399b60f7cb4cc3695b2d990d4a44094532a6b8c5751430eeb1f446 templates/E-ad 9924816cbbeade6f88f79d3e06fe04d143d801783888210ac2325925d69e4bdb tests/test_check_distribution.py b046f2eea794070194294a33f2914e627eed384e63fccffc2ac46693db2a968c tests/test_check_licenses.py 9a106ff5182b4a15713575de42e90b0dc5cdeebcb17ba08d97522a4c9aa6b2fa tests/test_check_work_order_dispatch.py -2257ab23529c93b974ec5cf9c506ab67b9e8f7a1896cad18d31c7dddba17af2e tests/test_coordinator_release.py +948288d5fc8b4a379d0e56f6a0a214a819ab4cd9b5af3d9914401e62892f95e1 tests/test_coordinator_release.py 9badf3dc8e783db4dc6f3ab3af8246e2da34d111ba936466e9e2055ca8665c63 tests/test_distribution.py e150a2f988a4b0beac5f70644f55f5e185a8aa575e988a19642bafabc0f07775 tests/test_identity_migration.py 11cd8090dbc53e8aa6a2f14cb181c8a11696335da8f40700eae5116798e49ba5 tests/test_init_sh.py a2df93f79791a884d9c6c2db308591a3b18e95ee34702ad5330ccc7a4b683fa4 tests/test_name_clearance.py 677d5b532450ace267be9c834269c081368697cd83defa5531ce673f6d0ca252 tests/test_privacy_screen.py a4135c3da96be9ce1769e62d07e1bca52517e3918d7f945212fcbb661ac3a7cc tests/test_public_projection.py -4658309c242c743026c121a12861162c443c8068b710639cfbabfbbb183fcac7 tests/test_start_writwall.py +72b5be64e0ca38a068b635bba35f254982f4294b29797b486424c19809c58fba tests/test_start_writwall.py +956592c25765630d862f872e3bc556fd488e238f4859b68d9a4486830db7a510 tests/test_uninstall_writwall.py 0684c04067eb95eadc9f72ab126d8662b4a5e2005c80b2dea174075a6140eebc tests/test_wo_capability_wall.py e8caf7f4421dc7f78b0d766741ec2ef4c2ac6dab6117fab6e4175b27d31e4d49 writwall_cli/__init__.py -31e39820e3a747f97cb9fc2fc0e1062108913c7a8755b88eb6810af3d55429ad writwall_cli/__main__.py +74636e969688b24c0cb52f5dc046261803036ca989eb1a51a83db1526db4a61b writwall_cli/__main__.py 76def9691b26cf48f692ae7a921ae3a52f8ccfa302724ad12debec2bf8bf36ac writwall_cli/coordinator.py diff --git a/PROJECTION-PROVENANCE.md b/PROJECTION-PROVENANCE.md index 07707d5..0af160c 100644 --- a/PROJECTION-PROVENANCE.md +++ b/PROJECTION-PROVENANCE.md @@ -5,9 +5,9 @@ Legacy commit identifiers in projected records refer to that private source and are intentionally not resolvable from fresh public history. No private remote URL is recorded here. -- Source commit: `86da925db1baee92fa56dc09d5e699a10dea3a2c` -- Source commit time: `2026-09-17T13:21:49-05:00` -- Projection allowlist SHA-256: `28ba77c4dfd9370bfe9fca2d349cd042c9a55ac8c6f1c09efca70e73a86d278f` +- Source commit: `ea663fe2edebbc8bcf8ee3d9e4f518ca96350295` +- Source commit time: `2026-09-21T19:34:53-05:00` +- Projection allowlist SHA-256: `c216ab40e0a7e6c4d38e1e225c8eedec57f361418e4bcbcf974ce8d02bd804b7` ## Legacy identifier inventory @@ -24,5 +24,6 @@ No private remote URL is recorded here. - `a905c87987f31094121c11a3b8163f97ef1abcf4` — `SELF-HOSTING.md`, `governance/STATE.md`, `governance/decisions/DR-001.md` - `ba3c0754e5019f1fa93779d110843562cfa07307` — `governance/STATE.md` - `d790a2b8d500a1c3a5e10af9f0a78d1c3c3f4e3a` — `governance/STATE.md` +- `ddacb3367a97641540b19d3a478cab96e436cfac` — `governance/STATE.md` - `e0cef360843dff38d6a02dd48be8f61b2d2d300e` — `governance/PLAN.md` - `e270fd3235d170a28a21fd198b88857740b74acd` — `governance/STATE.md` diff --git a/PUBLICATION.md b/PUBLICATION.md index cd60e4d..95045b8 100644 --- a/PUBLICATION.md +++ b/PUBLICATION.md @@ -13,7 +13,7 @@ point. Before creating a release tag, run this gate against the final external candidate on native Windows and native Ubuntu, naming the exact intended tag: ```text -python checks/check_coordinator_release.py --expected-tag v0.12.0 +python checks/check_coordinator_release.py --expected-tag v0.13.0 ``` For a future GitHub release that is required to be immutable, save the diff --git a/README.md b/README.md index 11b98c1..d239e76 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +> **Final recovery release: v0.13.0.** New adoption is discouraged. Existing users can leave Writwall without a work order or Doctrine migration. See [emergency uninstall](docs/uninstall.md) and [what failed](docs/recovery-postmortem.md). The earlier adoption guidance below is retained for reference. +

Writwall: document-governed AI work with scoped grants, denial evidence, and human acceptance.

@@ -97,7 +99,7 @@ actually blocks the current session before real work begins. Release `v0.12.0` has one canonical lifecycle and two ordinary entry commands: ```text -python -m pip install "https://github.com/HLLMR/writwall/archive/refs/tags/v0.12.0.zip" +python -m pip install "https://github.com/HLLMR/writwall/archive/refs/tags/v0.13.0.zip" # New idea or clean project: create a temporary local handoff writwall start --project-root /path/to/your-project diff --git a/REUSE.toml b/REUSE.toml index 28c72db..8a19583 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -21,6 +21,8 @@ path = [ "docs/identity-migration.md", "docs/name-clearance.md", "docs/privacy-screen.md", + "docs/uninstall.md", + "docs/recovery-postmortem.md", "examples/**", "governance/**", "identity/**", diff --git a/START-HERE.md b/START-HERE.md index c7c83df..4831714 100644 --- a/START-HERE.md +++ b/START-HERE.md @@ -1,3 +1,5 @@ +> **Final recovery release: v0.13.0.** New adoption is discouraged. Existing users can leave Writwall without a work order or Doctrine migration. See [emergency uninstall](docs/uninstall.md) and [what failed](docs/recovery-postmortem.md). The earlier adoption guidance below is retained for reference. + # Start here: the human operating guide You do not need to understand the Doctrine before beginning. You need to know @@ -168,7 +170,7 @@ not clear results. Install it without unpacking it over your project: ```text - python -m pip install "https://github.com/HLLMR/writwall/archive/refs/tags/v0.12.0.zip" + python -m pip install "https://github.com/HLLMR/writwall/archive/refs/tags/v0.13.0.zip" ``` Release `v0.9.0` first introduced the coordinator. Release `v0.9.1` corrected diff --git a/checks/check_coordinator_release.py b/checks/check_coordinator_release.py index 5075b91..4e29bcc 100644 --- a/checks/check_coordinator_release.py +++ b/checks/check_coordinator_release.py @@ -30,6 +30,7 @@ "writwall_cli/coordinator.py", "scripts/start_writwall.py", "scripts/privacy_screen.py", + "scripts/uninstall_writwall.py", "skills/writwall-adopt/SKILL.md", ) REQUIRED_HANDOFF_PATHS = ( @@ -185,6 +186,30 @@ class ReleaseCheckError(RuntimeError): Draft rejected alternatives. """ +# WO-WW-033: a synthetic historical record whose frontmatter exceeds the +# coordinator's ratified metadata bounds. Closed-history status is optional +# label-refinement evidence, never adoption or current authority, so an +# unreadable header must not block supported read-only re-entry. The padding +# length is deliberately independent of the coordinator's private constant: +# this gate measures an external candidate's real installed behavior. Entirely +# synthetic; no private or real historical bytes are copied. +OVERSIZED_HISTORY_RECORD_NAME = "WO-SYNTHETIC-OVERSIZED.md" +OVERSIZED_HISTORY_HEADER_SENTINEL = "SYNTHETIC-HISTORY-HEADER-SENTINEL" +OVERSIZED_HISTORY_BODY_SENTINEL = "SYNTHETIC-HISTORY-BODY-SENTINEL" +OVERSIZED_HISTORY_RECORD = ( + "---\nid: WO-SYNTHETIC-OVERSIZED\n" + f"note: {OVERSIZED_HISTORY_HEADER_SENTINEL}\n" + "padding: " + "x" * 9000 + "\n" + "status: CLOSED\n---\n" + f"{OVERSIZED_HISTORY_BODY_SENTINEL} synthetic body content\n" +) +# Kept equivalent to the aggregate limitation line emitted by +# scripts/start_writwall.py's classifier for exactly one unreadable record. +INCOMPLETE_HISTORY_EVIDENCE = ( + "historical work-order metadata was unreadable or out of bounds " + "for 1 record(s); closed-history evidence is incomplete" +) + UNRELATED_RATIFIED_DECISION = """# DR-001: Naming decision Ratified by the Owner on 2026-01-01. This record ratifies a project naming @@ -439,6 +464,46 @@ def verify_operational_preflight_absent(text: str, surface: str) -> None: ) +def check_installed_uninstall(command, candidate, workspace, environment): + """Exercise emergency exit through the installed command outside its target.""" + project = workspace / "broken-adopter" + hooks = project / ".claude" / "hooks" + hooks.mkdir(parents=True) + shutil.copyfile(candidate / "skills/writwall-adopt/assets/adapters/claude-code/wo_capability_wall.py", + hooks / "wo_capability_wall.py") + settings = project / ".claude" / "settings.json" + settings.write_text(json.dumps({"permissions": {"deny": ["Read(.env)"]}, + "hooks": {"PreToolUse": [{"matcher": "*", "hooks": [ + {"type": "command", "command": 'python3 "${CLAUDE_PROJECT_DIR}/.claude/hooks/wo_capability_wall.py"'}, + {"type": "command", "command": "echo host-audit"}]}]}}), encoding="utf-8") + (project / ".claude" / "active-wo.txt").write_text("../../missing\n", encoding="utf-8") + (project / ".git").write_text("gitdir: ../owner-worktree\n", encoding="utf-8") + (project / "app.txt").write_bytes(b"uncommitted host application\n") + original = tree_digest(project) + preview = run([str(command), "uninstall", "--project-root", str(project)], + cwd=workspace, environment=environment, label="installed uninstall preview") + json.loads(preview.stdout) + if tree_digest(project) != original: + raise ReleaseCheckError("uninstall preview changed target bytes") + plan = workspace / "exit-plan.json" + plan.write_text(preview.stdout, encoding="utf-8") + backup = workspace / "exit-backup" + applied = run([str(command), "uninstall", "--project-root", str(project), "--apply", + "--plan", str(plan), "--backup-root", str(backup)], cwd=workspace, + environment=environment, label="installed uninstall apply") + after = json.loads(settings.read_text(encoding="utf-8")) + if (after.get("permissions") != {"deny": ["Read(.env)"]} + or "echo host-audit" not in settings.read_text(encoding="utf-8") + or "wo_capability_wall.py" in settings.read_text(encoding="utf-8") + or (project / "app.txt").read_bytes() != b"uncommitted host application\n"): + raise ReleaseCheckError("uninstall failed surgical settings preservation") + journal = json.loads(applied.stdout)["journal"] + run([str(command), "uninstall", "--restore", journal], + cwd=workspace, environment=environment, label="installed uninstall restore") + if tree_digest(project) != original: + raise ReleaseCheckError("uninstall restore did not reproduce original target bytes") + + def check_candidate(candidate: Path, expected_tag: str) -> None: candidate = candidate.resolve() if not candidate.is_dir(): @@ -531,6 +596,7 @@ def check_candidate(candidate: Path, expected_tag: str) -> None: ) if "inspect" not in root_help.stdout: raise ReleaseCheckError("installed help omitted the inspect command") + check_installed_uninstall(command, candidate, workspace, environment) if (candidate / "PROJECTION-PROVENANCE.md").is_file(): for verb in ("inspect", "start"): @@ -949,6 +1015,128 @@ def check_candidate(candidate: Path, expected_tag: str) -> None: "installed retired-lockout route changed target bytes" ) + # WO-WW-033: a supported ratified project carrying one oversized + # synthetic history header must still produce meaningful read-only + # Architect re-entry on the real installed command, state the + # limitation as an aggregate count, and disclose no historical + # pathname, header, or body. Byte-level read-boundary behavior is + # covered by source tests; this gate measures installed output and + # zero-mutation only, and claims no raw-I/O instrumentation. + unreadable_history = workspace / "unreadable-history-project" + unreadable_governance = unreadable_history / "governance" + unreadable_decisions = unreadable_governance / "decisions" + unreadable_decisions.mkdir(parents=True) + (unreadable_history / "CLAUDE.md").write_text( + "# Charter\n\nA.1 Prohibitions apply.\n", encoding="utf-8", newline="\n" + ) + for name in ("PLAN.md", "STATE.md", "ROUTING.md"): + (unreadable_governance / name).write_text( + f"# {name}\n", encoding="utf-8", newline="\n" + ) + (unreadable_decisions / "DR-001.md").write_text( + RATIFIED_ADOPTION_RECORD, encoding="utf-8", newline="\n" + ) + unreadable_history_dir = unreadable_governance / "history" + unreadable_history_dir.mkdir() + (unreadable_history_dir / "WO-001.md").write_text( + "---\nid: WO-001\nstatus: CLOSED\n---\n", encoding="utf-8", newline="\n" + ) + (unreadable_history_dir / OVERSIZED_HISTORY_RECORD_NAME).write_text( + OVERSIZED_HISTORY_RECORD, encoding="utf-8", newline="\n" + ) + # A named temporary scope, created and populated by this harness only, + # with TMP/TEMP/TMPDIR redirected to it for these two inspections + # alone. The comparison below is a before/after digest snapshot of that + # scope: it proves no residue was left behind, not that no syscall + # occurred. It is not syscall interception and makes no claim about a + # transient file created and removed within a single run. + inspection_temp = workspace / "inspection-temp" + inspection_temp.mkdir() + (inspection_temp / "harness-fixture.txt").write_text( + "Harness-created fixture. The inspector must leave this scope " + "unchanged.\n", + encoding="utf-8", newline="\n", + ) + inspection_environment = dict(environment) + inspection_environment.update({ + "TMP": str(inspection_temp), + "TEMP": str(inspection_temp), + "TMPDIR": str(inspection_temp), + }) + inspection_temp_before = tree_digest(inspection_temp) + unreadable_before = tree_digest(unreadable_history) + unreadable_profile_before = profile_state_snapshot(state) + for surface, extra in (("full", ()), ("brief", ("--brief",))): + unreadable_result = run( + [ + str(command), "inspect", + "--project-root", str(unreadable_history), + "--role", "architect", *extra, + ], + cwd=workspace, environment=inspection_environment, + label=f"installed unreadable-history inspect ({surface})", + closed_stdin=True, + ) + unreadable_stdout = unreadable_result.stdout + missing_unreadable_text = [ + text for text in ( + "Observed lifecycle state: retired_lockout", + "Selected role: Fresh Architect", + INCOMPLETE_HISTORY_EVIDENCE, + "1 closed work-order record", + ) if text not in unreadable_stdout + ] + if missing_unreadable_text: + raise ReleaseCheckError( + f"installed unreadable-history inspect ({surface}) omitted: " + + ", ".join(missing_unreadable_text) + ) + if surface == "brief": + verify_installed_brief_contract( + unreadable_stdout, "unreadable history" + ) + elif "Begin read-only" not in unreadable_stdout: + raise ReleaseCheckError( + "installed unreadable-history inspect (full) omitted a " + "meaningful Fresh Architect handoff" + ) + disclosed = [ + item for item in ( + OVERSIZED_HISTORY_RECORD_NAME, + OVERSIZED_HISTORY_HEADER_SENTINEL, + OVERSIZED_HISTORY_BODY_SENTINEL, + "governance/history", + ) if item in unreadable_stdout + ] + if disclosed: + raise ReleaseCheckError( + f"installed unreadable-history inspect ({surface}) disclosed " + "historical material: " + ", ".join(disclosed) + ) + if (unreadable_history / ".writwall-bootstrap").exists(): + raise ReleaseCheckError( + "installed unreadable-history inspect created a bootstrap directory" + ) + unreadable_residue = python_bytecode_residue(unreadable_history) + if unreadable_residue: + raise ReleaseCheckError( + "installed unreadable-history inspect left bytecode residue: " + + ", ".join(unreadable_residue) + ) + if tree_digest(unreadable_history) != unreadable_before: + raise ReleaseCheckError( + "installed unreadable-history inspect changed target bytes" + ) + if profile_state_snapshot(state) != unreadable_profile_before: + raise ReleaseCheckError( + "installed unreadable-history inspect mutated isolated profile state" + ) + if tree_digest(inspection_temp) != inspection_temp_before: + raise ReleaseCheckError( + "installed unreadable-history inspect left residue in its " + "isolated temporary scope" + ) + active_project = workspace / "active-work-order-project" active_governance = active_project / "governance" active_governance.mkdir(parents=True) @@ -1074,6 +1262,12 @@ def check_candidate(candidate: Path, expected_tag: str) -> None: (unrelated_decisions / "DR-001.md").write_text( UNRELATED_RATIFIED_DECISION, encoding="utf-8", newline="\n" ) + # WO-WW-033 control: unreadable historical metadata must never relax + # this current-authority rejection. + (unrelated_governance / "history").mkdir() + (unrelated_governance / "history" / OVERSIZED_HISTORY_RECORD_NAME).write_text( + OVERSIZED_HISTORY_RECORD, encoding="utf-8", newline="\n" + ) unrelated_before = tree_digest(unrelated) unrelated_result = subprocess.run( [str(command), "start", "--project-root", str(unrelated)], @@ -1094,6 +1288,16 @@ def check_candidate(candidate: Path, expected_tag: str) -> None: "installed coordinator reported a lockout state for a signed " "but unrelated document at the exact adoption-record path" ) + unrelated_disclosed = [ + item for item in ( + OVERSIZED_HISTORY_HEADER_SENTINEL, OVERSIZED_HISTORY_BODY_SENTINEL + ) if item in unrelated_output + ] + if unrelated_disclosed: + raise ReleaseCheckError( + "installed unrelated-signed-decision regression disclosed " + "synthetic historical material: " + ", ".join(unrelated_disclosed) + ) if tree_digest(unrelated) != unrelated_before: raise ReleaseCheckError( "installed unrelated-signed-decision regression changed target bytes" @@ -1137,6 +1341,7 @@ def check_candidate(candidate: Path, expected_tag: str) -> None: print("OK: coordinator release candidate passed") print(f" installed version : {expected_version}") + print(" emergency exit : installed preview, surgical disable, and exact restore passed with a broken active pointer") print(" installed command : help and real start passed under normal bytecode behavior") print(" conversation-first: bare installed start produced the Architect handoff") print(" complete handoff : all required packets present; no bytecode residue") @@ -1147,6 +1352,12 @@ def check_candidate(candidate: Path, expected_tag: str) -> None: print(" draft regression : draft adoption record never reports adopted/retired lockout") print(" unrelated regression: signed unrelated document at the exact adoption-record") print(" path fails closed; zero target-byte change") + print(" unreadable history: an oversized synthetic history header still yields " + "meaningful Architect re-entry (full and --brief) with an aggregate " + "incomplete-evidence warning, no historical pathname/header/body " + "disclosure, and zero target/bootstrap/bytecode/profile mutation " + "plus an unchanged redirected temporary scope (before/after snapshot, " + "not syscall interception); current-authority rejection is unchanged") print(" nested worktree : installed coordinator stops with a worktree diagnostic") print(" candidate unchanged: complete-tree digest preserved") print(" installed brief : new/adopted/active --brief produced labeled sections, " diff --git a/docs/recovery-postmortem.md b/docs/recovery-postmortem.md new file mode 100644 index 0000000..e739830 --- /dev/null +++ b/docs/recovery-postmortem.md @@ -0,0 +1,70 @@ +# Why Writwall ends with v0.13.0 + +The Owner designated v0.13.0 as Writwall's final recovery release on +2026-09-21. It provides a way to leave an existing installation. New adoption +is discouraged. The distributed Doctrine remains 0.9; installing recovery +tooling does not migrate a project's adopted Doctrine or ratify new policy. + +## What failed + +An interrupted project task was handed from one provider to another. The +replacement recovered the active authorization, unfinished files, missing +verification, and prior exceptions. However, the replacement could not run +the builds and tests required by that same authorization. + +The canonical Claude adapter explicitly denies Bash and PowerShell even when +`shell.execute` says `restricted` or `allowed`. It cannot prove that arbitrary +shell commands preserve its protected control plane. That implementation is +consistent with its narrow enforcement design, but incompatible with an +ordinary build/test workflow represented as executable by the work order. +The provider switch exposed a capability mismatch; the grant did not make +the missing capability available. A denied canary demonstrated interception, +not the ability to complete authorized work. + +Retiring an order only removes its active pointer. The installed hook then +denies mutation without an active order. Retirement was never an uninstall, +and the system lacked an independent Owner-operated exit. + +Current truth was duplicated across the charter, state record, work order, +activation record, and session narrative. Those records drifted. Recovering +them took effort, and changing the execution environment generated more +recordkeeping instead of restoring the interrupted workflow. + +The installation process did not keep a complete ownership manifest. Settings +were merged manually and project-specific decisions lived alongside Writwall +records. Blanket deletion of the settings file or governance directory is not +a safe general-purpose uninstaller. + +## Recovery advice also failed + +The assisting Architect supplied a nonexistent integration-branch placeholder, +mixed two worktree sequences, and supplied a PowerShell argument form that +failed when writing a recovery patch. It proposed removing the entire settings +file before checking for unrelated hooks. Those instructions increased the +Owner's burden during an incident. They are not a recovery interface to reuse. + +An equal ZIP byte length was also treated as proof of equal payload content. +It is not. Different archive hashes require inspection of member contents and +metadata before assigning the difference to timestamps. + +The Owner-provided incident reports support these conclusions. This release +does not independently attest to the affected project's source, tests, cloud +state, or deployment. No adopter's files or private records are shipped here. + +## What this release changes + +An Owner can run the uninstall tool from a normal terminal outside the blocked +agent session. It does not require an active work order, valid adoption record, +or successful governance parser. Preview, explicit application, external +backups, and restore are separate operations. Only recognized hook entries are +removed from shared settings; unrelated entries remain. Document removal is +explicit and bounded, because filenames cannot establish ownership. + +Recovery does not relax the wall to allow arbitrary shell execution, claim a +provider-neutral sandbox, or convert previous UNKNOWN/FAIL evidence to PASS. +The Owner decides which project records to retain. Exit is independent of +Writwall's authorization loop, and no uninstall operation commits, pushes, +deploys, or edits application code on the Owner's behalf. + +See [the emergency exit guide](uninstall.md) for the tested command interface, +its limits, and the final manual checks for a fresh agent session. diff --git a/docs/uninstall.md b/docs/uninstall.md new file mode 100644 index 0000000..82829e8 --- /dev/null +++ b/docs/uninstall.md @@ -0,0 +1,129 @@ +# Emergency exit from Writwall + +v0.13.0 is the final recovery release. It adds `writwall uninstall`. Existing +projects do not need to adopt a new Doctrine revision, close their work order, +repair governance records, or install a hook to use it. + +## Run as the Owner + +Close agents working in the target repository. Run these commands in your own +terminal, outside the agent's blocked tool session. Keep other writers stopped +until apply or restore finishes. This is an Owner-operated maintenance tool, +not a way for an agent to ignore a denied tool call. + +Install the recovery CLI into a separate tooling environment, or unpack the +v0.13.0 source distribution outside your project and run its standalone +`scripts/uninstall_writwall.py` with Python 3.10 or newer. Do not unpack a +Writwall distribution over your application's files. The standalone script +uses only the Python standard library and does not require installation, +network access, or imports from the target repository. + +The installed entry point and standalone script accept the same arguments. +In the examples below, set the three paths yourself once; the plan and backup +directory must be outside the target project. Use a durable local backup +location, not temporary storage that may be cleared automatically. +Use physical paths: symlink or junction aliases are refused, including macOS +`/var` and `/tmp` aliases. Their physical locations normally start with +`/private/var` and `/private/tmp`. Links inside the selected project remain +refused as well; do not resolve a linked project file to bypass that check. +On macOS/Linux, run `pwd -P` in the intended directory to obtain its physical +path before supplying it to the tool. +Plans and backups can contain private project configuration; keep them local +and do not attach them to a public issue or release. + +```powershell +$project = Read-Host 'Full path of the project to remove Writwall from' +$plan = Read-Host 'Full path for a new uninstall plan JSON outside the project' +$backup = Read-Host 'Full path for a new backup directory outside the project' + +writwall uninstall --project-root "$project" --plan-output "$plan" +``` + +Read the plan. Preview does not change the project. It identifies the exact +settings edits, removals, preserved material, and blockers. It never asks an +agent to interpret a grant. Missing, stale, malformed, or oversized work orders +do not determine whether the Owner can leave. + +## Apply the reviewed plan + +```powershell +writwall uninstall --project-root "$project" --apply --plan "$plan" --backup-root "$backup" +``` + +Application rechecks the plan against current file bytes. A changed candidate +requires a new preview. Originals and a recovery journal are preserved outside +the repository before changes. Keep the printed journal path for restoration. +Unrelated settings and hook entries are retained. Modified files may be +reformatted JSON; the original bytes are in the backup. + +Default recovery removes recognized Writwall hook registrations. It does not +delete an entire settings file just because one Writwall hook was installed. +Unrecognized wrappers or malformed settings require explicit manual review; +the tool must not claim that those registrations have been removed. + +For an older or customized installation, preview can explicitly select an +exact Writwall command with `--remove-hook-command`. Copy the entire command +from your settings and pass it as one argument; do not substitute a substring. +The reviewed plan binds that selection and the current settings bytes. This +selection is an Owner decision, not proof that arbitrary custom code belongs +to Writwall. Both `.claude/settings.json` and `.claude/settings.local.json` +are inspected; user-level and enterprise settings remain outside this tool. + +If a settings file is malformed, preserve its exact bytes outside the project, +then repair its JSON in an editor while removing only the Writwall hook entry. +Do not delete the whole file to remove one hook. Preview again after that +repair. A process whose project settings cannot be parsed is not a successful +uninstall, even if the hook happens not to execute. + +## Remove instructions and documents deliberately + +Hook removal frees the provider tool interface; it does not erase instructions +that an agent might still load. Review retained `CLAUDE.md`, `AGENTS.md`, skill +configuration, and project documents before starting a new agent. + +Use repeatable `--remove-path` options during preview to select supported +Writwall files for removal, then apply that new reviewed plan. Each selected +file is backed up. Only select files whose complete contents you intend to +remove. A mixed project/Writwall document should instead be edited manually +after retaining its original, preserving the project's build instructions, +architecture decisions, safety constraints, and current work. + +The tool intentionally does not recursively delete `governance/`, infer that +all Markdown files are Writwall-owned, or rewrite your project's state. Legacy +installations have no complete ownership manifest. Remaining unknown or mixed +files are reported for Owner disposition rather than silently destroyed. + +Retain useful decisions and unresolved tasks in ordinary project documents or +your issue tracker. Preserve original reviews and evidence as local records; +Git does not back up untracked files. The uninstall backup covers only the +files in its plan, not the whole repository or every untracked artifact. + +## Restore or recover an interrupted application + +Use the journal path printed by apply: + +```text +writwall uninstall --restore PATH-TO-JOURNAL +``` + +Restoration verifies backups and refuses to overwrite unrelated newer edits. +Restoration preserves file bytes, not original ACLs, executable bits, timestamps, +or other filesystem metadata. Check any custom permissions before resuming. +If application was interrupted, keep the complete backup directory and use +its journal. Do not hand-edit journal hashes to force acceptance. Inspect any +reported conflicts before proceeding. + +## Verify the exit + +Inspect the resulting diff and run your application's normal checks. Start a +fresh agent session and inspect its loaded hooks and instructions. Confirm that +Writwall's hook is absent, then run an ordinary local build or test. Other +provider, user-level, enterprise-managed, or Git hook policies may still apply; +the tool does not disable those. No new session is qualified merely because a +JSON file changed. + +The tool never creates a branch, stages, commits, pushes, merges, deploys, +executes application code, or changes cloud resources. Review and commit the +cleanup through your project's normal process. It leaves history and the +installed Python package in place; package-manager removal is optional after +recovery, and removing a Python package alone does not remove project hooks. diff --git a/governance/LOG-denials.jsonl b/governance/LOG-denials.jsonl index 1c7e987..eb62252 100644 --- a/governance/LOG-denials.jsonl +++ b/governance/LOG-denials.jsonl @@ -342,3 +342,5 @@ {"schema":1,"timestamp":"2026-09-17T16:31:27Z","session_id":"c1496e3e-7712-48e0-9a1d-543b27ab0ee9","tool":"Write","surface":"filesystem.write","work_order":"governance/work-orders/WO-WW-032-v0.12.0-delivery.md","decision":"deny","reason_code":"write_target_out_of_grant","reason":"Write target is outside grant.filesystem.write."} {"schema":1,"timestamp":"2026-09-17T16:47:24Z","session_id":"51df66e8-5726-4e29-a83b-22859f915c82","tool":"Read","surface":"filesystem.read","work_order":"governance/work-orders/WO-WW-032-v0.12.0-delivery.md","decision":"deny","reason_code":"read_target_outside_repository","reason":"Read target could not be resolved inside the repository."} {"schema":1,"timestamp":"2026-09-17T16:47:25Z","session_id":"51df66e8-5726-4e29-a83b-22859f915c82","tool":"Read","surface":"filesystem.read","work_order":"governance/work-orders/WO-WW-032-v0.12.0-delivery.md","decision":"deny","reason_code":"read_target_outside_repository","reason":"Read target could not be resolved inside the repository."} +{"schema":1,"timestamp":"2026-09-17T21:54:15Z","session_id":"2820dec3-8bdc-49d2-a1a0-fdc3c38d455d","tool":"Write","surface":"filesystem.write","work_order":"governance/work-orders/WO-WW-033.md","decision":"deny","reason_code":"write_target_out_of_grant","reason":"Write target is outside grant.filesystem.write."} +{"schema":1,"timestamp":"2026-09-17T22:32:02Z","session_id":"f6dd6260-64d4-4f37-8ef8-b2f2a9a92bce","tool":"Write","surface":"filesystem.write","work_order":"governance/work-orders/WO-WW-033.md","decision":"deny","reason_code":"write_target_out_of_grant","reason":"Write target is outside grant.filesystem.write."} diff --git a/governance/LOG.md b/governance/LOG.md index 6b52869..6badbc8 100644 --- a/governance/LOG.md +++ b/governance/LOG.md @@ -1307,3 +1307,45 @@ Fresh distinct public-candidate Opus inspected17 paths and returned ACCEPT-READY with no blocking findings; execution/hashes were supplied Coordinator evidence. No new account or host/system package change. Final postcloseout qualification and authorized publication remain subsequent gates, not claimed complete here. + +## Post-pilot WO-WW-033 completed record — 2026-09-17 + +Owner acceptance and the exact bounded closeout recorder exception are recorded +in the acceptance task for this checkpoint. Active minutes NOT REPORTED; this +is not another counted pilot item. Operative Doctrine 0.8 is unchanged. + +The coordinator treats unavailable bounded historical metadata as incomplete +optional evidence, preserving current-authority and path-safety failures. +The 8192-byte and 200-line limits are enforced before another read. Historical +bodies and original evidence were not repaired or retrieved. + +Two distinct native sessions each passed one excluded Write canary before +their in-grant writes: denial records 345 and 346, original prefixes preserved, +target absent. Whole-surface classification remains 8 declared / 0 wholly +enforced / 8 unenforced. General's instruction-bounded Codex closeout exception +is separately Owner-authorized; it is not session-local wall proof or a +same-order retirement mechanism. RFI-24 remains DEFERRED. + +Pre-closeout verification on the approved unpublished public-source derivative: +Windows 853 tests PASS / 14 skips / 464.553 seconds; native Ubuntu 853 tests +PASS / 4 skips / 125.502 seconds. Installed, distribution, license and identity +gates passed on both. Raw-read tests and real installed synthetic regressions +cover the defect. Temporary snapshots show no retained mutation, not syscall +interception. Earlier RED, environmental failures and review corrections remain +recorded. Normalized rework/drift counts and Owner reading time NOT MEASURED; +no operating-cost or new pilot-success claim. A subsequent preinstallation +command omitted --no-cache-dir and reported a wheel in the application pip +cache outside the approved resources. That resource deviation is disclosed +for Owner acknowledgment; the cache was left untouched. + +Independent review returned CONFORMANCE PASS, with execution results supplied +by General rather than independently rerun. Issue #28 is recommended for +closure on merged public delivery; #41 remains OPEN for the responsibility-view +and test-coverage residuals. No external issue action occurred; #38 is separate. + +The eight WO-WW-033 records were retired byte-identically. The work order's +ACTIVE header is preserved as its historical issuance snapshot; it grants no +authority after retirement and is not counted as CLOSED-history evidence. +Current acceptance/retirement is recorded here and in State. Final actual-root +qualification follows the private closeout commit and returns separately in +the task against the exact tested commit. No publication or successor follows. diff --git a/governance/PLAN.md b/governance/PLAN.md index 64dd53e..8f03d0b 100644 --- a/governance/PLAN.md +++ b/governance/PLAN.md @@ -1,5 +1,11 @@ # PLAN — Plumbline +## Owner-ratified reading clarification — 2026-09-17 + +This Plan preserves successive ratified intent and dated amendments. Its original title and section 3, “Current phase,” are retained evidence from earlier checkpoints, not the current identity, adoption, lifecycle, or publication status. Read the newest acceptance checkpoint in `governance/STATE.md`, its corresponding `governance/LOG.md` entry, and the current activation pointer alongside the applicable later Plan amendments and decisions. Plan remains ratified intent; State and Log record observations and accepted completion. Neither a dated observation nor this reading guide grants new execution authority. + +Section 36 authorized v0.12.0 delivery through WO-WW-032, including the accepted WO-WW-027/028/029 and WO-WW-031 work. WO-WW-033 instead received separate explicit Owner approval in its acceptance task for the bounded continuity scope and exact recorder exceptions, as recorded in the WO-WW-033 completion entry in `governance/LOG.md`. It did not inherit successor authority from section 36. The absence of separately numbered Plan sections for these orders does not reopen their recorded approvals or require retroactive ratification. Consult the newest State and corresponding Log records for subsequent delivery and lifecycle observations; this note changes no adoption boundary, standing recorder authority, or deferred RFI disposition. + **Status: RATIFIED by the Owner (HLLMR) on 2026-08-16**, for the self-adoption and 10-work-order pilot. Scope: the **repository-development role**. Root `decisions/DR-001.md` is Plan for the distinct **methodology-source role** and ratifies Doctrine 0.6. The two do not overlap (adoption mapping, 2026-08-16). diff --git a/governance/STATE.md b/governance/STATE.md index 9f1e59d..83ac922 100644 --- a/governance/STATE.md +++ b/governance/STATE.md @@ -2,7 +2,63 @@ # STATE — Writwall -## Current closeout checkpoint — 2026-09-17, WO-WW-032 +## Current direction — final recovery release v0.13.0, 2026-09-21 + +The Owner directed the final v0.13.0 recovery release: investigate the failed +provider handoff, provide an Owner-operated emergency uninstall, and end +Writwall development with that release. This source prepares that recovery +tool and its verification; the published tag and release assets establish +delivery, not this snapshot. No adopter is migrated or uninstalled by updating +the tooling. Private adopted Doctrine 0.8 and distributed Doctrine 0.9 remain +distinct. New adoption is discouraged. See `docs/uninstall.md` and +`docs/recovery-postmortem.md`. + +This isolated release branch excludes the main checkout's unfinished +WO-WW-034 changes. Earlier checkpoints below are dated historical observations, +not current execution instructions for this final Owner-directed release. + +## Dated acceptance and retirement checkpoint — WO-WW-033 + +**OBSERVED:** This checkpoint records the Owner-authorized acceptance and +retirement of WO-WW-033, before the final actual-root qualification. No active +work order remains; the repository is in lockout. No successor is authorized by +this checkpoint. The correction is private and unpublished; public `v0.12.0` +remains commit `ddacb3367a97641540b19d3a478cab96e436cfac`, observed 2026-09-17. +Doctrine 0.8 remains operative here under `governance/decisions/DR-003.md` (private governed-source record, not carried by public candidates) and +`CLAUDE.md`; distributed Doctrine 0.9 does not migrate this repository. +WO-WW-032 remains accepted and retired in current records. + +Public issues #28 and #41 were externally OPEN as observed 2026-09-17. The +private evidence-based recommendation for #28 is closure on the merged +delivery, subject to maintainer judgment. For #41 the recommendation is to +**keep the issue open**: the published contract covers every named scenario, +but two criteria are verified as unmet — the responsibility view omits +coordinator, executor, and evidence age, and several acceptance tests rest on +bare-token assertions, one of which is satisfied by an unrelated word. A +bounded residual follow-up is named in the issue-disposition record; it is not +scoped, started, or authorized here. No external comment, label, or close action +was taken. Issue #38 remains separate and out of scope. + +RFI-24 remains DEFERRED +(`governance/rfis/RFI-24-active-work-order-retirement-mechanics.md`). The +retirement procedure used here is a bounded coordinated closeout, not a +universal agent-retirement fix, and does not dispose of that RFI. + +At this checkpoint final actual-root qualification has not yet run; the gate +follows the closeout commit. Its later read-only source and installed full and +brief checks, and the fresh Architect's findings, return in this task through +General to Architect to Owner, naming the exact tested closeout commit. This +snapshot does not claim that later result; re-entry must recheck the current +checkout. + +Evidence limitations at this checkpoint: no historical body was read, and +closed-history status is optional refinement evidence only — where a record's +metadata is unreadable within the ratified bounds, the coordinator reports an +aggregate count and never a pathname. Whole-surface enforcement classification +is unchanged and no surface is represented as newly proven. This checkpoint +adds no roadmap. + +## Dated snapshot — 2026-09-17, WO-WW-032 **OBSERVED:** Owner accepted WO-WW-032 including disclosed deviations and directed authorized closeout and v0.12.0 publication. Active minutes NOT @@ -26,7 +82,7 @@ sidebar. Evidence: governance/history/WO-WW-032-report.md and (private governed- governance/history/WO-WW-032-issuance-lifecycle.md (private governed-source (private governed-source reference, not present in this candidate) references, not present in the public candidate). -## Current closeout checkpoint — 2026-09-17, WO-WW-031 +## Dated snapshot — 2026-09-17, WO-WW-031 **OBSERVED:** Owner accepted031 with "Accepted, proceed", including disclosed deviations; active minutes NOT REPORTED. Final native Windows848 total/13 skips @@ -49,7 +105,7 @@ Evidence: governance/history/WO-WW-031-acceptance-closeout.md and (private gover governance/history/WO-WW-031-report.md (private governed-source references, (private governed-source reference, not present in this candidate) not present in the public candidate). -## Latest bounded checkpoint — 2026-09-16, WO-WW-030 +## Dated snapshot — 2026-09-16, WO-WW-030 **OBSERVED:** Owner accepted WO-WW-030 with its disclosed deviations and ratified the exact Doctrine 0.9 candidate. Active minutes NOT REPORTED. Independent Opus @@ -79,7 +135,7 @@ Evidence: `governance/history/WO-WW-030-ratification-closeout.md` and (private g references, not present in the public candidate). Older checkpoints below are dated snapshots, not current execution or authorization status. -## Latest bounded checkpoint — 2026-09-16, WO-WW-029 +## Dated snapshot — 2026-09-16, WO-WW-029 **OBSERVED:** Owner accepted WO-WW-029 with disclosed sequencing overlap, post-implementation coverage and environment diagnostics, plus a non-blocking @@ -111,7 +167,7 @@ Evidence: `governance/history/WO-WW-029-report.md` (private governed-source refe `governance/history/WO-WW-029-issuance-lifecycle.md` (private governed-source reference, not present in this candidate), and `governance/history/WO-WW-029-closeout-brief.md` (private governed-source reference, not present in this candidate). -## Latest bounded checkpoint — 2026-09-15, WO-WW-028 +## Dated snapshot — 2026-09-15, WO-WW-028 **OBSERVED:** Owner accepted WO-WW-028 with disclosed deviations and coverage limits; active minutes NOT REPORTED. The opt-in `inspect --brief` capability @@ -142,7 +198,7 @@ Evidence: `governance/history/WO-WW-028-report.md` (private governed-source refe `governance/history/WO-WW-028-issuance-lifecycle.md` (private governed-source reference, not present in this candidate), and `governance/history/WO-WW-028-closeout-brief.md` (private governed-source reference, not present in this candidate). -## Latest bounded checkpoint — 2026-09-14, WO-WW-027 +## Dated snapshot — 2026-09-14, WO-WW-027 **OBSERVED:** Owner accepted WO-WW-027, including disclosed deviations; active minutes NOT REPORTED. Approval-continuity source, installed-output checks and @@ -252,6 +308,7 @@ externally. --- ## OBSERVED +Dated snapshot tables. Rows record status as observed when written; they are not a current verification of GitHub, release, or issue state. ### Authority and adoption diff --git a/identity/legacy-references.json b/identity/legacy-references.json index 1c3ec9d..849de40 100644 --- a/identity/legacy-references.json +++ b/identity/legacy-references.json @@ -16,7 +16,7 @@ { "path": "README.md", "context": "migration_provenance", - "sha256": "4f49c016aba20e207a72f9e19c6a7057954e87c753f8208e31bce6c13b39f9aa" + "sha256": "d6275f7dcf3859ad26250101893686d741332ac1a05b3eaa43d3e8a4bc944578" }, { "path": "SELF-HOSTING.md", @@ -94,19 +94,19 @@ { "path": "governance/LOG.md", "context": "historical_pilot_summary", - "sha256": "ba4bd0d79505aa32782b41cf3143f27d2ed0509c2125482502b3737b9aeb754c", + "sha256": "4bcc8210fc26b67a3365202ce222f9763fc322b24cfad9d4084857ae8914b5ba", "projection_transform": "private_evidence_redaction" }, { "path": "governance/PLAN.md", "context": "ratified_historical_intent", - "sha256": "a6036b492d0f3aca7c5bd455ccc4314f3d67d1d407936f6c6e17c55e4a39f250" + "sha256": "cb2ea71cc5d7e7b1a62dcbb54f3677d4e9ce3a2c66fea63f9fb818dfd57dda73" }, { "path": "governance/STATE.md", "context": "mixed_current_state_and_history", - "sha256": "85cf867035693dfa0019acd9db0151ac95dc98dd45cf31a052d32a8d9edf9508", - "projection_sha256": "7421478c01f4e343c3d255b3daa22e9cb3ce232915ab87c1e490f6be1e79fea3" + "sha256": "ef47713ee4f639f85d8278977290da15dca00afc1524234be8df673fbbffe858", + "projection_sha256": "6198ae29bf1006dac6f1cd44f50abae22511e929ef53eb3db77681f195fd99c1" }, { "path": "governance/decisions/DR-001.md", @@ -122,7 +122,7 @@ { "path": "projection/public-files.txt", "context": "historical_path_index", - "sha256": "28ba77c4dfd9370bfe9fca2d349cd042c9a55ac8c6f1c09efca70e73a86d278f" + "sha256": "c216ab40e0a7e6c4d38e1e225c8eedec57f361418e4bcbcf974ce8d02bd804b7" }, { "path": "tests/test_distribution.py", diff --git a/projection/public-files.txt b/projection/public-files.txt index d924854..fa5dd4e 100644 --- a/projection/public-files.txt +++ b/projection/public-files.txt @@ -52,6 +52,8 @@ docs/day-zero-coordinator.md docs/identity-migration.md docs/name-clearance.md docs/privacy-screen.md +docs/recovery-postmortem.md +docs/uninstall.md examples/README.md examples/name-clearance-incident-2026-08.md examples/name-clearance-ledgers/grantcord-candidate.json @@ -95,6 +97,7 @@ scripts/build_public_projection.py scripts/collect_name_clearance.py scripts/privacy_screen.py scripts/start_writwall.py +scripts/uninstall_writwall.py skills/writwall-adopt/LICENSE-MAP.md skills/writwall-adopt/SKILL.md skills/writwall-adopt/assets/adapters/claude-code/README.md @@ -131,6 +134,7 @@ tests/test_name_clearance.py tests/test_privacy_screen.py tests/test_public_projection.py tests/test_start_writwall.py +tests/test_uninstall_writwall.py tests/test_wo_capability_wall.py writwall_cli/__init__.py writwall_cli/__main__.py diff --git a/pyproject.toml b/pyproject.toml index 7e60df8..4405ba4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta" [project] name = "writwall" -version = "0.12.0" -description = "Start governed project work from an idea." +version = "0.13.0" +description = "Final Writwall recovery release with an Owner-operated emergency uninstall." requires-python = ">=3.10" license = "Apache-2.0" license-files = ["LICENSES/Apache-2.0.txt"] diff --git a/scripts/start_writwall.py b/scripts/start_writwall.py index aeee695..b7e9235 100644 --- a/scripts/start_writwall.py +++ b/scripts/start_writwall.py @@ -503,7 +503,11 @@ def _bounded_frontmatter_status(path: Path) -> str | None: requests) and reads exactly one raw byte at a time, assembling only complete header lines. It stops issuing further reads the instant the closing delimiter line's own trailing newline has been consumed -- no - chunked read-ahead, and never a byte beyond that boundary is requested. A + chunked read-ahead, and never a byte beyond that boundary is requested. + Both budgets are checked *before* each single-byte request, so the byte + after the byte cap and the first byte of the line after the line cap are + never read at all; a header whose closing delimiter falls exactly on + either cap still parses normally. A header exceeding the byte or line bound, one missing a closing delimiter within that bound, or one whose collected bytes are not valid frontmatter text is explicit malformed metadata -- never a silent guess and never a @@ -515,39 +519,43 @@ def _bounded_frontmatter_status(path: Path) -> str | None: lines: list[bytes] = [] current_line = bytearray() total_bytes = 0 + lines_read = 0 closing_found = False try: with path.open("rb", buffering=0) as handle: - while True: - byte = handle.read(1) - if not byte: - break - total_bytes += 1 - if total_bytes > _MAX_FRONTMATTER_BYTES: + while not closing_found: + # Both budgets are enforced before the next single-byte + # request, never after consuming the byte that breaches them. + if lines_read >= _MAX_FRONTMATTER_LINES: + raise CoordinatorError( + "cannot read historical work-order record: frontmatter " + f"exceeds the {_MAX_FRONTMATTER_LINES}-line bound" + ) + if total_bytes >= _MAX_FRONTMATTER_BYTES: raise CoordinatorError( "cannot read historical work-order record: frontmatter " f"exceeds the {_MAX_FRONTMATTER_BYTES}-byte bound" ) + byte = handle.read(1) + if not byte: + break + total_bytes += 1 if byte != b"\n": current_line += byte continue line = bytes(current_line) current_line = bytearray() - if not lines: + lines_read += 1 + if lines_read == 1: if line.startswith(_UTF8_BOM): line = line[len(_UTF8_BOM):] if line.rstrip(b"\r") != b"---": return None lines.append(b"---") continue - if len(lines) > _MAX_FRONTMATTER_LINES: - raise CoordinatorError( - "cannot read historical work-order record: frontmatter " - f"exceeds the {_MAX_FRONTMATTER_LINES}-line bound" - ) if line.rstrip(b"\r") == b"---": closing_found = True - break + continue lines.append(line.rstrip(b"\r")) except OSError as exc: raise CoordinatorError(f"cannot read historical work-order record: {exc}") from exc @@ -710,6 +718,7 @@ def reject_bootstrap_conflict(lifecycle: str) -> None: ) history = governance / "history" closed_records = [] + indeterminate_history = 0 if _entry_exists(history): resolved_history = _safe_project_path(project, history, "history directory") if not resolved_history.is_dir(): @@ -720,7 +729,20 @@ def reject_bootstrap_conflict(lifecycle: str) -> None: raise CoordinatorError( "inconsistent state: historical work-order record is not a file" ) - if _bounded_frontmatter_status(safe_path) in {"CLOSED", "COMPLETE"}: + # Closed-history status only refines the retired/adopted lockout + # label; it is never adoption authority and never current + # authority. A record whose bounded metadata cannot be read is + # therefore unavailable optional evidence, reported as an + # aggregate count, not a contradiction that stops entry. This + # catch is scoped to the bounded metadata read alone: the + # containment, link-safety, and regular-file checks above still + # fail closed, and neither bound is relaxed. + try: + status = _bounded_frontmatter_status(safe_path) + except CoordinatorError: + indeterminate_history += 1 + continue + if status in {"CLOSED", "COMPLETE"}: closed_records.append(safe_path) resolved_adoption_paths = [] @@ -781,6 +803,17 @@ def reject_bootstrap_conflict(lifecycle: str) -> None: ) adopted = bool(ratified_evidence) + # An aggregate count only: never a historical pathname, header byte, body + # byte, or per-record reason text. + history_limitation = ( + ( + "historical work-order metadata was unreadable or out of bounds " + f"for {indeterminate_history} record(s); closed-history evidence " + "is incomplete", + ) + if indeterminate_history else () + ) + if all(path.is_file() for path in core) and adopted and closed_records: reject_bootstrap_conflict("retired lockout") return ObservedState( @@ -790,6 +823,7 @@ def reject_bootstrap_conflict(lifecycle: str) -> None: "Plan, State, and Routing exist", f"ratified adoption evidence observed: {ratified_evidence[0].relative}", f"{len(closed_records)} closed work-order record(s) observed in history", + *history_limitation, ), ) if all(path.is_file() for path in core) and adopted: @@ -800,6 +834,7 @@ def reject_bootstrap_conflict(lifecycle: str) -> None: "activation pointer is absent", "Plan, State, Routing, and ratified adoption evidence exist: " f"{ratified_evidence[0].relative}", + *history_limitation, ), ) @@ -1577,6 +1612,21 @@ def _lockout_aggregate_history_line(state: ObservedState) -> str | None: return None +_HISTORY_LIMITATION_MARKER = "closed-history evidence is incomplete" + + +def _lockout_history_limitation_line(state: ObservedState) -> str | None: + """The classifier's own aggregate unreadable-history count, if any. + + Deliberately keyed on a marker distinct from the closed-record count + line, so the two aggregate statements never shadow one another. + """ + for item in state.evidence: + if _HISTORY_LIMITATION_MARKER in item: + return item + return None + + def _render_lockout_brief( project: Path, state: ObservedState, selected_role: str ) -> str: @@ -1616,6 +1666,16 @@ def _render_lockout_brief( "body is read or indexed by this brief)." if history_line else "" ) + limitation_line = _lockout_history_limitation_line(state) + limitation_evidence_line = ( + f"- {limitation_line} (the classifier's already-computed aggregate; " + "this brief performs no additional history read and indexes no " + "historical pathname, header, body, or reason text)." + if limitation_line else "" + ) + history_evidence_block = "\n".join( + line for line in (history_evidence_line, limitation_evidence_line) if line + ) is_architect = selected_role == "Fresh Architect" next_step = ( @@ -1639,7 +1699,7 @@ def _render_lockout_brief( - Canonical project root: {project.as_posix()} - Observed lifecycle state: {state.name} - Selected role: {selected_role} -{history_evidence_line} +{history_evidence_block} ## Decision and authority references diff --git a/scripts/uninstall_writwall.py b/scripts/uninstall_writwall.py new file mode 100644 index 0000000..b4a6775 --- /dev/null +++ b/scripts/uninstall_writwall.py @@ -0,0 +1,311 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 HLLMR Ventures LLC +# SPDX-License-Identifier: Apache-2.0 +"""Standalone, reversible Writwall hook off-ramp. No governance dependencies.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import stat +import sys +import tempfile +import uuid + +SETTINGS = '.claude/settings.json' +LOCAL_SETTINGS = '.claude/settings.local.json' +POINTER = '.claude/active-wo.txt' +HOOK = '.claude/hooks/wo_capability_wall.py' +COMMANDS = {f'{exe} "${{CLAUDE_PROJECT_DIR}}/{HOOK}"' for exe in ('py -3', 'python3')} +CANONICAL_HOOK_SHA256 = 'dd29af2a39d25e0270ad9acc23ee912f81e39c674e1179759f4a3010c6a0c1a0' + + +class RecoveryError(ValueError): + pass + + +def digest(data): + return hashlib.sha256(data).hexdigest() if data is not None else None + + +def safe_path(path): + """Reject redirections in every existing path component, including junctions.""" + path = Path(os.path.abspath(path)) + for part in (*reversed(path.parents), path): + if part.is_symlink(): + raise RecoveryError(f'Link refused: {part}') + try: + info = part.lstat() + except FileNotFoundError: + continue + if getattr(info, 'st_file_attributes', 0) & 0x400: + raise RecoveryError(f'Reparse point refused: {part}') + if stat.S_ISREG(info.st_mode) and info.st_nlink != 1: + raise RecoveryError(f'Hardlink refused: {part}') + return path + + +def read_file(path): + path = safe_path(path) + if not path.exists(): + return None + if not path.is_file(): + raise RecoveryError(f'Expected regular file: {path}') + return path.read_bytes() + + +def decode_json(data): + def unique(pairs): + result = {} + for key, value in pairs: + if key in result: + raise RecoveryError(f'Duplicate JSON key: {key}') + result[key] = value + return result + return json.loads(data.decode('utf-8-sig'), object_pairs_hook=unique) + + +def canonical_digest(): + return CANONICAL_HOOK_SHA256 + + +def relative_target(root, rel): + if not isinstance(rel, str) or '\\' in rel or ':' in rel: + raise RecoveryError('Invalid relative target') + parts = rel.split('/') + reserved = {'con', 'prn', 'aux', 'nul', 'clock$', 'conin$', 'conout$'} + reserved.update(f'{prefix}{digit}' for prefix in ('com', 'lpt') for digit in '123456789¹²³') + if any(p.casefold() in ('', '.', '..', '.git') or p.endswith((' ', '.')) + or p.split('.')[0].casefold() in reserved + or any(ord(c) < 32 or c in '<>"|?*' for c in p) for p in parts): + raise RecoveryError('Invalid relative target') + return safe_path(root / rel) + + +def target_key(path): + # Reject Windows aliases on every host so a portable plan keeps its meaning. + return str(path).casefold() + + +def root_identity(root): + info = root.stat() + return [info.st_dev, info.st_ino] + + +def preview(project_root, remove_paths=(), remove_hook_commands=()): + root = safe_path(project_root) + if not root.is_dir() or root == root.parent: + raise RecoveryError('Project root must be an existing non-filesystem-root directory') + selected_commands = sorted(set(remove_hook_commands)) + if any(not isinstance(c, str) or 'wo_capability_wall' not in c for c in selected_commands): + raise RecoveryError('Explicit hook selection must name an exact Writwall hook command') + removals = sorted(set(remove_paths)) + selected_targets = set() + for rel in removals: + target = relative_target(root, rel) + key = target_key(target) + if key in selected_targets: + raise RecoveryError('Duplicate or aliased removal target') + selected_targets.add(key) + data = read_file(target) + if data is None or (rel not in (HOOK, POINTER) and (target.suffix.lower() != '.md' or b'writwall' not in data.lower())): + raise RecoveryError('Selected removal must be a regular Writwall-identified Markdown document or canonical hook') + original = read_file(root / SETTINGS) + local_original = read_file(root / LOCAL_SETTINGS) + hook = read_file(root / HOOK) + known = hook is not None and digest(hook.replace(b'\r\n', b'\n')) == canonical_digest() + warnings = [] + removed = 0 + changes = [] + matched_commands = set() + for settings_rel, settings_bytes in ((SETTINGS, original), (LOCAL_SETTINGS, local_original)): + if settings_bytes is None: + continue + count = 0 + obj = decode_json(settings_bytes) + if not isinstance(obj, dict): + raise RecoveryError('Settings must be a JSON object') + hooks = obj.get('hooks', {}) + if not isinstance(hooks, dict): + raise RecoveryError('Settings hooks must be an object') + for event, entries in hooks.items(): + if not isinstance(entries, list): + raise RecoveryError('Hook events must contain arrays') + for entry in entries: + if not isinstance(entry, dict) or not isinstance(entry.get('hooks'), list): + raise RecoveryError('Malformed hook registration') + kept = [] + for registration in entry['hooks']: + if not isinstance(registration, dict): + raise RecoveryError('Malformed hook command') + command = registration.get('command', '') + if (registration.get('type') == 'command' and isinstance(command, str) + and ((event == 'PreToolUse' and command in COMMANDS and known) or command in selected_commands)): + removed += 1 + count += 1 + matched_commands.add(command) + else: + kept.append(registration) + if isinstance(command, str) and 'wo_capability_wall' in command: + warnings.append('Unrecognized or modified Writwall registration preserved: ' + command) + entry['hooks'] = kept + if count: + result = (json.dumps(obj, indent=2, ensure_ascii=False) + '\n').encode('utf-8') + changes.append({'path': settings_rel, 'before': digest(settings_bytes), 'after': digest(result), + 'content': result.decode('utf-8')}) + if set(selected_commands) - matched_commands: + raise RecoveryError('Explicit hook command selection did not match a registration') + if not (root / '.git').exists() and not (known or removed or warnings or removals): + raise RecoveryError('Non-Git directory has no recognized Writwall artifacts') + if HOOK in removals and (not known or warnings): + raise RecoveryError('Cannot remove unverified hook or hook with preserved registrations') + for rel in removals: + changes.append({'path': rel, 'before': digest(read_file(root / rel)), 'after': None, 'content': None}) + residual = [rel for rel in ('CLAUDE.md', 'AGENTS.md', '.claude/CLAUDE.md') + if rel not in removals and read_file(root / rel) is not None] + return {'version': 1, 'project_root': str(root), 'root_identity': root_identity(root), + 'residual_instruction_files': residual, 'fully_removed': False, + 'status': 'manual_attention_required' if warnings or residual else 'known_registration_disable_only', + 'settings_sha256': digest(original), + 'local_settings_sha256': digest(local_original), 'remove_hook_commands': selected_commands, + 'hook_sha256': digest(hook), 'canonical_hook_sha256': canonical_digest(), + 'remove_paths': removals, 'registrations_removed': removed, 'warnings': warnings, + 'changes': changes} + + +def outside(path, root): + path = safe_path(path) + if path == root or root in path.parents: + raise RecoveryError('Backup and journal must be outside the project') + return path + + +def atomic_write(path, data): + path = safe_path(path) + descriptor, temporary = tempfile.mkstemp(prefix='.ww-recovery-', dir=str(path.parent)) + try: + with os.fdopen(descriptor, 'wb') as stream: + stream.write(data) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + + +def apply_plan(plan, backup_root): + root = safe_path(plan['project_root']) + current = preview(root, plan['remove_paths'], plan['remove_hook_commands']) + if current != plan: + raise RecoveryError('Plan drift detected; generate and inspect a fresh preview') + backup = outside(backup_root, root) + backup.mkdir(parents=True, exist_ok=True) + session = backup / ('writwall-recovery-' + uuid.uuid4().hex) + session.mkdir() + entries = [] + for index, change in enumerate(plan['changes']): + data = read_file(root / change['path']) + target = session / f'{index}.original' + atomic_write(target, data) + if digest(read_file(target)) != change['before']: + raise RecoveryError('Backup did not match approved original; no targets changed') + entries.append({'path': change['path'], 'backup': target.name, + 'before': digest(data), 'after': change['after']}) + journal = session / 'journal.json' + record = {'version': 1, 'project_root': str(root), 'root_identity': root_identity(root), 'entries': entries} + atomic_write(journal, (json.dumps(record, indent=2) + '\n').encode()) + # All original bytes and the recovery journal exist before the first mutation. + try: + for change in plan['changes']: + path = root / change['path'] + if digest(read_file(path)) != change['before']: + raise RecoveryError(f'Late drift detected; recover using {journal}') + if change['content'] is None: + path.unlink() + else: + atomic_write(path, change['content'].encode('utf-8')) + except (OSError, KeyboardInterrupt) as exc: + raise RecoveryError(f'Apply interrupted ({type(exc).__name__}); recovery journal: {journal}') from exc + return {'journal': str(journal), 'changed_paths': [c['path'] for c in plan['changes']], + 'warnings': plan['warnings'], 'status': plan['status'], + 'residual_instruction_files': plan['residual_instruction_files'], 'fully_removed': False} + + +def restore(journal_path): + journal_path = safe_path(journal_path) + record = decode_json(read_file(journal_path)) + root = safe_path(record['project_root']) + if root_identity(root) != record['root_identity']: + raise RecoveryError('Project root identity changed') + outside(journal_path, root) + pending = [] + seen = set() + for entry in record['entries']: + rel = entry['path'] + target = relative_target(root, rel) + key = target_key(target) + if (rel not in (SETTINGS, LOCAL_SETTINGS, HOOK, POINTER) and target.suffix.lower() != '.md') or key in seen: + raise RecoveryError('Invalid journal target') + seen.add(key) + name = entry['backup'] + if not isinstance(name, str) or Path(name).name != name or '/' in name or '\\' in name: + raise RecoveryError('Invalid backup name') + original = read_file(journal_path.parent / name) + if original is None or digest(original) != entry['before']: + raise RecoveryError('Backup missing or hash mismatch') + actual = digest(read_file(root / rel)) + if actual not in (entry['before'], entry['after']): + raise RecoveryError(f'Restore refused: changed since uninstall: {rel}') + if actual != entry['before']: + pending.append((root / rel, original, actual)) + for path, original, expected in pending: + if digest(read_file(path)) != expected: + raise RecoveryError('Late drift during restore') + atomic_write(path, original) + return {'restored_paths': [str(p.relative_to(root)) for p, _, _ in pending], + 'journal': str(journal_path)} + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--project-root', default='.') + parser.add_argument('--remove-path', action='append', default=[]) + parser.add_argument('--remove-hook-command', action='append', default=[]) + parser.add_argument('--apply', action='store_true') + parser.add_argument('--plan') + parser.add_argument('--plan-output', help='Write preview JSON as UTF-8 to a new external file') + parser.add_argument('--backup-root') + parser.add_argument('--restore') + args = parser.parse_args(argv) + try: + if args.restore: + if args.apply or args.plan or args.remove_path or args.backup_root or args.plan_output or args.remove_hook_command: + raise RecoveryError('Restore cannot be combined with apply or preview options') + result = restore(args.restore) + elif args.apply: + if args.plan_output or args.remove_path or args.remove_hook_command: + raise RecoveryError('Apply consumes the exact plan; no additional preview options allowed') + if not args.plan or not args.backup_root: + raise RecoveryError('Apply requires --plan and external --backup-root') + plan = decode_json(read_file(args.plan)) + if safe_path(args.project_root) != safe_path(plan['project_root']): + raise RecoveryError('Plan belongs to another project root') + result = apply_plan(plan, args.backup_root) + else: + if args.plan or args.backup_root: + raise RecoveryError('--plan and --backup-root require --apply') + result = preview(args.project_root, args.remove_path, args.remove_hook_command) + if args.plan_output: + output = outside(args.plan_output, safe_path(args.project_root)) + with output.open('x', encoding='utf-8', newline='\n') as stream: + stream.write(json.dumps(result, indent=2) + '\n') + print(json.dumps(result, indent=2)) + return 0 + except (OSError, ValueError, TypeError, KeyError, AttributeError) as exc: + print(json.dumps({'error': str(exc)}), file=sys.stderr) + return 2 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tests/test_coordinator_release.py b/tests/test_coordinator_release.py index 34e9d1d..0ddf7a1 100644 --- a/tests/test_coordinator_release.py +++ b/tests/test_coordinator_release.py @@ -26,6 +26,7 @@ "writwall_cli/coordinator.py", "scripts/start_writwall.py", "scripts/privacy_screen.py", + "scripts/uninstall_writwall.py", "skills/writwall-adopt", ) @@ -51,7 +52,7 @@ def setUp(self) -> None: def run_checker(self, candidate: Path, *extra: str): arguments = [str(candidate), *extra] if "--expected-tag" not in extra: - arguments.extend(("--expected-tag", "v0.12.0")) + arguments.extend(("--expected-tag", "v0.13.0")) return subprocess.run( [sys.executable, "-B", str(CHECKER), *arguments], cwd=REPO_ROOT, @@ -157,6 +158,7 @@ def test_complete_external_candidate_installs_and_emits_full_handoff(self): self.assertIn("retired lockout", result.stdout) self.assertIn("draft regression", result.stdout) self.assertIn("unrelated regression", result.stdout) + self.assertIn("unreadable history", result.stdout) self.assertIn("zero target-byte change", result.stdout) self.assertIn("candidate unchanged", result.stdout) self.assertEqual(tree_digest(candidate), before) @@ -298,6 +300,47 @@ def test_installed_release_gate_catches_missing_operational_preflight_requiremen ) self.assertEqual(tree_digest(candidate), before) + def test_installed_reintroduced_oversized_history_defect_fails_release_gate(self): + """The new installed gate must actually catch the demonstrated bug. + + Reintroduce the pre-repair behavior in a synthetic candidate -- an + unreadable historical header aborting classification instead of being + counted as unavailable optional evidence -- and require the real + installed-wheel checker to reject it. This is a genuine defect + injection against installed output, not a source grep and not a + mocked collaborator. The unmodified-candidate case is already covered + by `test_complete_external_candidate_installs_and_emits_full_handoff`, + which also pins the checker's aggregate-warning constant to whatever + the classifier actually emits. + """ + candidate = self.make_candidate() + start = candidate / "scripts" / "start_writwall.py" + original = start.read_text(encoding="utf-8") + scoped_catch = ( + " try:\n" + " status = _bounded_frontmatter_status(safe_path)\n" + " except CoordinatorError:\n" + " indeterminate_history += 1\n" + " continue\n" + ) + self.assertIn(scoped_catch, original) + start.write_text( + original.replace( + scoped_catch, + " status = _bounded_frontmatter_status(safe_path)\n", + ), + encoding="utf-8", + newline="\n", + ) + before = tree_digest(candidate) + result = self.run_checker(candidate) + self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn( + "installed unreadable-history inspect", + result.stdout + result.stderr, + ) + self.assertEqual(tree_digest(candidate), before) + def test_installed_help_mismatch_fails_with_diagnostic(self): candidate = self.make_candidate() entry = candidate / "writwall_cli" / "__main__.py" @@ -510,15 +553,15 @@ def test_intended_release_tag_must_match_candidate_metadata(self): pyproject = candidate / "pyproject.toml" pyproject.write_text( pyproject.read_text(encoding="utf-8").replace( - 'version = "0.12.0"', 'version = "0.9.0"' + 'version = "0.13.0"', 'version = "0.9.0"' ), encoding="utf-8", newline="\n", ) - result = self.run_checker(candidate, "--expected-tag", "v0.12.0") + result = self.run_checker(candidate, "--expected-tag", "v0.13.0") self.assertNotEqual(result.returncode, 0) self.assertIn( - "candidate version '0.9.0' does not match intended tag 'v0.12.0'", + "candidate version '0.9.0' does not match intended tag 'v0.13.0'", result.stdout + result.stderr, ) @@ -564,7 +607,7 @@ def test_release_check_exercises_nested_worktree_stop_on_the_installed_wheel(sel def test_release_identity_and_public_payload_are_coherent(self): with (REPO_ROOT / "pyproject.toml").open("rb") as handle: project = tomllib.load(handle)["project"] - self.assertEqual(project["version"], "0.12.0") + self.assertEqual(project["version"], "0.13.0") readme = (REPO_ROOT / "README.md").read_text(encoding="utf-8") adopting = (REPO_ROOT / "ADOPTING.md").read_text(encoding="utf-8") contributing = (REPO_ROOT / "CONTRIBUTING.md").read_text(encoding="utf-8") @@ -572,17 +615,17 @@ def test_release_identity_and_public_payload_are_coherent(self): start = (REPO_ROOT / "START-HERE.md").read_text(encoding="utf-8") skill = (REPO_ROOT / "skills" / "writwall-adopt" / "SKILL.md").read_text( encoding="utf-8") - tagged_archive = "archive/refs/tags/v0.12.0.zip" + tagged_archive = "archive/refs/tags/v0.13.0.zip" self.assertIn(tagged_archive, readme) self.assertIn(tagged_archive, adopting) self.assertIn(tagged_archive, start) - self.assertIn("--expected-tag v0.12.0", publication) - self.assertIn("--expected-tag v0.12.0", contributing) + self.assertIn("--expected-tag v0.13.0", publication) + self.assertIn("--expected-tag v0.13.0", contributing) for document in (readme, adopting, start): self.assertNotIn("not yet published", document) self.assertIn( 'python -m pip install ' - '"https://github.com/HLLMR/writwall/archive/refs/tags/v0.12.0.zip"', + '"https://github.com/HLLMR/writwall/archive/refs/tags/v0.13.0.zip"', document, ) self.assertIn("writwall inspect", document) diff --git a/tests/test_start_writwall.py b/tests/test_start_writwall.py index e998381..b18fd27 100644 --- a/tests/test_start_writwall.py +++ b/tests/test_start_writwall.py @@ -3363,6 +3363,606 @@ def test_active_work_order_routes_to_bounded_operator_implementer(self): self.assertIn("Observed lifecycle state: active_work_order", result.stdout) self.assertIn("Operator", result.stdout) + # -- WO-WW-033 RED: reliable read-only re-entry into a supported + # adopted/retired lockout. Closed-history status is optional *refinement* + # evidence: within `classify_project` it only distinguishes the + # `retired_lockout` and `adopted_lockout` labels, and both route to the + # same next role. It is never current authority. A historical record whose + # metadata cannot be read within the ratified Amendment 1 bounds is + # therefore unavailable optional evidence, not a contradiction, and must + # not abort classification for the whole project. The bounds themselves, + # the raw no-read-ahead guarantee, and every current-authority and + # link-safety contradiction stay exactly as strict as they are today. + # + # Every fixture below is entirely synthetic. No real historical, archived, + # RFI, dist, private or other-project record is created, read or referenced. + + UNREADABLE_HISTORY_RECORD_NAME = "WO-HIST-UNREADABLE.md" + + @staticmethod + def incomplete_history_evidence(count: int) -> str: + """The exact aggregate limitation string the repaired classifier must + report: a count only, never a historical pathname, reason text or body. + """ + return ( + "historical work-order metadata was unreadable or out of bounds " + f"for {count} record(s); closed-history evidence is incomplete" + ) + + def unreadable_history_bytes(self, variant: str) -> bytes: + """Synthetic historical records exercising each genuine bounded-reader + failure mode: the byte bound, a missing closing delimiter, the line + bound, and invalid header encoding. Each is an evidence-availability + limit, never a statement about current authority. + """ + if variant == "oversized_bytes": + return ( + b"---\nid: WO-HIST-UNREADABLE\npadding: " + + b"x" * (starter_module._MAX_FRONTMATTER_BYTES + 100) + + b"\nstatus: CLOSED\n---\n" + ) + if variant == "unterminated_header": + return b"---\nid: WO-HIST-UNREADABLE\nstatus: CLOSED\n" + if variant == "over_line_bound": + return ( + b"---\n" + + b"".join( + b"note-%03d: synthetic\n" % index + for index in range(starter_module._MAX_FRONTMATTER_LINES + 50) + ) + + b"status: CLOSED\n---\n" + ) + if variant == "malformed_encoding": + return ( + b"---\nid: WO-HIST-UNREADABLE\nnote: \xff\xfe\n" + b"status: CLOSED\n---\n" + ) + raise AssertionError(f"unknown unreadable-history variant {variant!r}") + + def write_unreadable_history_record( + self, governance: Path, variant: str = "oversized_bytes" + ) -> Path: + history = governance / "history" + history.mkdir(parents=True, exist_ok=True) + record = history / self.UNREADABLE_HISTORY_RECORD_NAME + record.write_bytes(self.unreadable_history_bytes(variant)) + return record + + def seed_lockout_project( + self, name: str, *, closed_history: bool, + unreadable_variant: str | None = "oversized_bytes", + ) -> Path: + """A supported, ratified adopted/retired lockout fixture, optionally + carrying one historical record whose metadata cannot be read. + """ + project = self.temp / name + governance = project / "governance" + governance.mkdir(parents=True) + (project / "CLAUDE.md").write_text( + "# Charter\n\nA.1 Prohibitions apply.\n", encoding="utf-8" + ) + for entry in ("PLAN.md", "STATE.md", "ROUTING.md"): + (governance / entry).write_text(f"# {entry}\n", encoding="utf-8") + decisions = governance / "decisions" + decisions.mkdir() + (decisions / "DR-001.md").write_text( + ratified_adoption_record(), encoding="utf-8" + ) + history = governance / "history" + history.mkdir() + if closed_history: + (history / "WO-HIST-CLOSED.md").write_text( + "---\nid: WO-HIST-CLOSED\nstatus: CLOSED\n---\n", + encoding="utf-8", + ) + if unreadable_variant is not None: + self.write_unreadable_history_record(governance, unreadable_variant) + return project + + def test_lockout_entry_survives_unreadable_history_metadata_in_every_bounded_failure_mode(self): + """RED: today a single historical record whose frontmatter exceeds the + 8192-byte bound (or the line bound, or lacks a closing delimiter, or + carries invalid header encoding) raises out of `classify_project` and + stops read-only entry entirely -- `inspect` exits 2 with STOP. + + The whole project is supported and ratified: the activation pointer is + absent, Plan/State/Routing exist, and a complete Appendix D adoption + record is present. None of those conclusions depends on historical + status. Entry must succeed, name the correct lifecycle, and state the + evidence limitation plainly as an aggregate count -- never counting an + unreadable record as closed, never naming a historical pathname, and + never widening the bound. + """ + for variant in ( + "oversized_bytes", "unterminated_header", + "over_line_bound", "malformed_encoding", + ): + for closed_history in (True, False): + lifecycle = "retired" if closed_history else "adopted" + with self.subTest(variant=variant, lifecycle=lifecycle): + project = self.seed_lockout_project( + f"lockout-{variant}-{lifecycle}", + closed_history=closed_history, + unreadable_variant=variant, + ) + expected = f"{lifecycle}_lockout" + before = self.tree_snapshot(project) + + result = self.run_inspect("architect", project) + + self.assertEqual( + result.returncode, 0, result.stdout + result.stderr + ) + self.assertEqual(self.tree_snapshot(project), before) + self.assertFalse(self.state.exists()) + self.assertIn( + f"Observed lifecycle state: {expected}", result.stdout + ) + self.assertIn( + "Selected role: Fresh Architect", result.stdout + ) + self.assertIn( + self.incomplete_history_evidence(1), result.stdout + ) + if closed_history: + self.assertIn( + "1 closed work-order record", result.stdout, + "the readable CLOSED record must still be counted", + ) + else: + self.assertNotIn( + "closed work-order record", result.stdout, + "an unreadable record must never be counted as closed", + ) + self.assertNotIn( + self.UNREADABLE_HISTORY_RECORD_NAME, result.stdout + ) + self.assertNotIn("governance/history", result.stdout) + + with self.subTest(surface="compact brief and General entry"): + project = self.seed_lockout_project( + "lockout-brief-and-general", closed_history=True, + ) + before = self.tree_snapshot(project) + + brief = self.run_inspect("architect", project, brief=True) + general = self.run_inspect("general", project) + + self.assertEqual(brief.returncode, 0, brief.stdout + brief.stderr) + self.assertEqual( + general.returncode, 0, general.stdout + general.stderr + ) + self.assertEqual(self.tree_snapshot(project), before) + self.assertFalse(self.state.exists()) + for stdout in (brief.stdout, general.stdout): + self.assertIn( + "Observed lifecycle state: retired_lockout", stdout + ) + self.assertNotIn(self.UNREADABLE_HISTORY_RECORD_NAME, stdout) + self.assertNotIn("governance/history", stdout) + self.assertIn("### Compact continuation brief", brief.stdout) + self.assertIn(self.incomplete_history_evidence(1), brief.stdout) + self.assertIn("1 closed work-order record", brief.stdout) + self.assertIn("Selected role: Fresh General", general.stdout) + + def test_unreadable_history_metadata_is_never_read_past_its_bound_or_disclosed(self): + """RED: the repair must not be bought with a larger bound or a body + read. Instruments the actual raw file-I/O boundary for one oversized + synthetic record carrying a sentinel inside its header and a second + sentinel in its body, well past the bound. + + Requires the record still be opened unbuffered (`buffering=0`, so no + internal buffered reader can pull body bytes into memory ahead of the + caller's own requests), sums every byte ever returned across every read + call, and asserts that total equals the configured byte cap exactly: + the budget is enforced before each request, so the byte after the cap + is never read at all. No returned chunk may carry + a body byte, and neither sentinel -- nor the historical pathname -- may + reach any rendered output, even though the header sentinel's bytes were + genuinely read. + """ + header_sentinel = b"HISTORICAL-HEADER-SENTINEL" + body_sentinel = b"HISTORICAL-BODY-SENTINEL" + project = self.seed_lockout_project( + "unreadable-history-privacy", closed_history=True, + unreadable_variant=None, + ) + record = ( + project / "governance" / "history" + / self.UNREADABLE_HISTORY_RECORD_NAME + ) + record.write_bytes( + b"---\nid: WO-HIST-UNREADABLE\nnote: " + header_sentinel + b"\n" + b"padding: " + + b"x" * (starter_module._MAX_FRONTMATTER_BYTES + 512) + + b"\nstatus: CLOSED\n---\n" + + body_sentinel + b" synthetic body content\n" + ) + before = self.tree_snapshot(project) + + raw_bytes_consumed = 0 + buffering_used: list[object] = [] + original_open = Path.open + test_case = self + + class _BoundedReadProxy: + """Forwards to the real handle while measuring every byte it ever + returns, at the exact boundary the production code calls.""" + + def __init__(self, handle): + self._handle = handle + + def __enter__(self): + return self + + def __exit__(self, *exc_info): + return self._handle.__exit__(*exc_info) + + @staticmethod + def _record_chunk(chunk: bytes) -> None: + nonlocal raw_bytes_consumed + raw_bytes_consumed += len(chunk) + test_case.assertNotIn( + body_sentinel, chunk, + "raw read consumed a byte of the historical body", + ) + + def read(self, size=-1, *args, **kwargs): + result = self._handle.read(size, *args, **kwargs) + self._record_chunk( + result if isinstance(result, (bytes, bytearray)) else b"" + ) + return result + + def readinto(self, buffer): + result = self._handle.readinto(buffer) + if result: + self._record_chunk(bytes(buffer[:result])) + return result + + def __getattr__(self, name): + return getattr(self._handle, name) + + def instrumented_open(self_path, *args, **kwargs): + handle = original_open(self_path, *args, **kwargs) + if self_path.name != record.name: + return handle + buffering_used.append( + kwargs.get("buffering", args[1] if len(args) > 1 else -1) + ) + return _BoundedReadProxy(handle) + + with mock.patch.object(Path, "open", instrumented_open): + state = starter_module.classify_project(project) + + self.assertEqual(state.name, "retired_lockout") + self.assertIn( + self.incomplete_history_evidence(1), "\n".join(state.evidence) + ) + self.assertTrue( + buffering_used, "expected the historical record to be opened" + ) + self.assertIn( + 0, buffering_used, + "the historical record must still be opened with buffering=0 " + "(raw, unbuffered binary I/O), so no internal buffer can pull " + "body bytes into memory ahead of the caller's own requests", + ) + self.assertEqual( + raw_bytes_consumed, starter_module._MAX_FRONTMATTER_BYTES, + "the reader must stop exactly at its configured byte cap: the " + "byte after the cap is never requested, the cap is not widened, " + "and the body is never reached", + ) + + output = io.StringIO() + with redirect_stdout(output): + exit_code = starter_module.inspect_main( + ["--project-root", str(project), "--role", "architect", + "--brief"] + ) + rendered = output.getvalue() + + self.assertEqual(exit_code, 0) + self.assertEqual(self.tree_snapshot(project), before) + self.assertIn(self.incomplete_history_evidence(1), rendered) + self.assertNotIn(header_sentinel.decode("ascii"), rendered) + self.assertNotIn(body_sentinel.decode("ascii"), rendered) + self.assertNotIn(self.UNREADABLE_HISTORY_RECORD_NAME, rendered) + self.assertNotIn("governance/history", rendered) + + def measure_bounded_read(self, record: Path): + """Run `_bounded_frontmatter_status` over one record, capturing every + raw byte it ever requests at the real file-I/O boundary. + + Returns (raised, status, consumed_bytes, buffering_used). + """ + chunks: list[bytes] = [] + buffering_used: list[object] = [] + original_open = Path.open + + class _MeasuringProxy: + def __init__(self, handle): + self._handle = handle + + def __enter__(self): + return self + + def __exit__(self, *exc_info): + return self._handle.__exit__(*exc_info) + + def read(self, size=-1, *args, **kwargs): + result = self._handle.read(size, *args, **kwargs) + if isinstance(result, (bytes, bytearray)): + chunks.append(bytes(result)) + return result + + def readinto(self, buffer): + result = self._handle.readinto(buffer) + if result: + chunks.append(bytes(buffer[:result])) + return result + + def __getattr__(self, name): + return getattr(self._handle, name) + + def instrumented_open(self_path, *args, **kwargs): + handle = original_open(self_path, *args, **kwargs) + if self_path.name != record.name: + return handle + buffering_used.append( + kwargs.get("buffering", args[1] if len(args) > 1 else -1) + ) + return _MeasuringProxy(handle) + + raised = None + status = None + with mock.patch.object(Path, "open", instrumented_open): + try: + status = starter_module._bounded_frontmatter_status(record) + except starter_module.CoordinatorError as exc: + raised = exc + return raised, status, b"".join(chunks), buffering_used + + def test_bounded_frontmatter_reader_stops_exactly_at_its_configured_byte_and_line_caps(self): + """RED: the issued bound is AT MOST 8192 bytes and 200 lines, so the + 8193rd byte and the first byte of the 201st line must never be + requested at all -- today the reader consumes the breaching byte and + only then rejects it. + + Instruments the real raw-I/O boundary directly. The configured limits + stay 8192/200; a header whose closing delimiter falls exactly on a cap + must still parse, so the tightening cannot be bought by rejecting + valid headers one unit early. Entirely synthetic records. + """ + byte_cap = starter_module._MAX_FRONTMATTER_BYTES + line_cap = starter_module._MAX_FRONTMATTER_LINES + + with self.subTest(cap="byte cap is never overrun"): + body_sentinel = b"BYTE-CAP-BODY-SENTINEL" + record = self.temp / "WO-BYTE-CAP.md" + record.write_bytes( + b"---\nid: WO-BYTE-CAP\npadding: " + + b"x" * (byte_cap + 512) + + b"\nstatus: CLOSED\n---\n" + body_sentinel + b"\n" + ) + + raised, status, consumed, buffering_used = self.measure_bounded_read(record) + + self.assertIsNotNone(raised) + self.assertIn(f"{byte_cap}-byte bound", str(raised)) + self.assertIsNone(status) + self.assertIn(0, buffering_used) + self.assertEqual( + len(consumed), byte_cap, + "the byte after the configured cap must never be requested", + ) + self.assertNotIn(body_sentinel, consumed) + + with self.subTest(cap="line cap is never overrun"): + line_sentinel = b"LINE-CAP-SENTINEL" + record = self.temp / "WO-LINE-CAP.md" + record.write_bytes( + b"---\n" + + b"".join( + b"note-%03d: synthetic\n" % index + for index in range(line_cap - 1) + ) + + b"note-xxx: " + line_sentinel + b"\n" + + b"status: CLOSED\n---\n" + ) + + raised, status, consumed, buffering_used = self.measure_bounded_read(record) + + self.assertIsNotNone(raised) + self.assertIn(f"{line_cap}-line bound", str(raised)) + self.assertIsNone(status) + self.assertIn(0, buffering_used) + self.assertEqual( + consumed.count(b"\n"), line_cap, + "at most the configured number of lines may be consumed, " + "counting the newline that terminates each one", + ) + self.assertNotIn( + line_sentinel, consumed, + "no byte of the line after the cap may be requested", + ) + self.assertLessEqual(len(consumed), byte_cap) + + with self.subTest(cap="a header exactly at the line cap still parses"): + record = self.temp / "WO-LINE-CAP-EXACT.md" + record.write_bytes( + b"---\n" + + b"".join( + b"note-%03d: synthetic\n" % index + for index in range(line_cap - 3) + ) + + b"status: CLOSED\n---\nsynthetic body content\n" + ) + + raised, status, consumed, _ = self.measure_bounded_read(record) + + self.assertIsNone(raised, "a header exactly at the line cap must parse") + self.assertEqual(status, "CLOSED") + self.assertEqual(consumed.count(b"\n"), line_cap) + self.assertNotIn(b"synthetic body content", consumed) + + def test_unreadable_history_metadata_never_relaxes_current_authority_or_link_safety(self): + """RED: the repair must be scoped to the bounded metadata read alone. + + With an unreadable historical record present in every fixture, each + current-lifecycle contradiction must fail closed exactly as it does + today, adoption must never be manufactured, and per-record containment + and link safety inside the history directory must still stop before any + external read. A blanket `except CoordinatorError` around the history + loop body would pass the two tests above and fail these. + """ + core = ("PLAN.md", "STATE.md", "ROUTING.md") + + def seed_core(name: str) -> Path: + project = self.temp / name + governance = project / "governance" + governance.mkdir(parents=True) + for entry in core: + (governance / entry).write_text(f"# {entry}\n", encoding="utf-8") + return project + + with self.subTest(case="draft_adoption_never_becomes_adopted"): + project = seed_core("unreadable-history-draft-adoption") + governance = project / "governance" + decisions = governance / "decisions" + decisions.mkdir() + (decisions / "DR-001.md").write_text( + draft_adoption_record(), encoding="utf-8" + ) + self.write_unreadable_history_record(governance) + before = self.tree_snapshot(project) + + result = self.run_inspect("auto", project) + + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(self.tree_snapshot(project), before) + self.assertIn( + "Observed lifecycle state: partial_bootstrap", result.stdout + ) + self.assertNotIn("adopted_lockout", result.stdout) + self.assertNotIn("retired_lockout", result.stdout) + + with self.subTest(case="malformed_adoption_record_still_fails_closed"): + project = seed_core("unreadable-history-malformed-adoption") + governance = project / "governance" + decisions = governance / "decisions" + decisions.mkdir() + (decisions / "DR-001.md").write_text( + unrelated_ratified_decision(), encoding="utf-8" + ) + self.write_unreadable_history_record(governance) + before = self.tree_snapshot(project) + + result = self.run_inspect("auto", project) + + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertEqual(self.tree_snapshot(project), before) + self.assertIn("inconsistent state", result.stderr) + self.assertIn("adoption-record title", result.stderr) + self.assertNotIn("Naming decision", result.stderr) + + with self.subTest(case="contradictory_adoption_records_still_fail_closed"): + project = seed_core("unreadable-history-contradictory-adoption") + governance = project / "governance" + decisions = governance / "decisions" + decisions.mkdir() + (decisions / "DR-001.md").write_text( + ratified_adoption_record(), encoding="utf-8" + ) + (governance / "ADOPTION-RECORD.md").write_text( + ratified_adoption_record( + title="# Adoption record (alternate path)", revision="0.6", + ), + encoding="utf-8", + ) + self.write_unreadable_history_record(governance) + before = self.tree_snapshot(project) + + result = self.run_inspect("auto", project) + + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertEqual(self.tree_snapshot(project), before) + self.assertIn("inconsistent state", result.stderr) + self.assertIn("contradictory", result.stderr) + + with self.subTest(case="active_order_without_pointer_still_fails_closed"): + project = seed_core("unreadable-history-active-without-pointer") + governance = project / "governance" + orders = governance / "work-orders" + orders.mkdir() + (orders / "WO-001.md").write_text( + "---\nid: WO-001\nstatus: ACTIVE\n---\n", encoding="utf-8" + ) + self.write_unreadable_history_record(governance) + before = self.tree_snapshot(project) + + result = self.run_inspect("auto", project) + + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertEqual(self.tree_snapshot(project), before) + self.assertIn( + "ACTIVE work order(s) exist without an activation pointer", + result.stderr, + ) + + with self.subTest(case="bootstrap_conflict_still_fails_closed"): + project = self.seed_lockout_project( + "unreadable-history-bootstrap-conflict", closed_history=False, + ) + (project / starter_module.OUTPUT_NAME).mkdir() + before = self.tree_snapshot(project) + + result = self.run_inspect("auto", project) + + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertEqual(self.tree_snapshot(project), before) + self.assertIn("inconsistent state", result.stderr) + self.assertIn(".writwall-bootstrap", result.stderr) + + with self.subTest(case="symlinked_history_record_still_fails_closed"): + external = self.temp / "external-history-record.md" + external.write_text( + "---\nid: WO-EXT\nstatus: CLOSED\n---\nSECRET-SENTINEL\n", + encoding="utf-8", + ) + project = self.seed_lockout_project( + "unreadable-history-symlinked-record", closed_history=False, + ) + linked = project / "governance" / "history" / "WO-LINKED.md" + try: + linked.symlink_to(external) + except OSError as exc: + self.skipTest(f"symlink privilege unavailable: {exc}") + before = self.tree_snapshot(project) + + result = self.run_inspect("auto", project) + + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertEqual(self.tree_snapshot(project), before) + self.assertIn("symlink", (result.stdout + result.stderr).lower()) + self.assertNotIn("SECRET-SENTINEL", result.stdout + result.stderr) + + with self.subTest(case="non_regular_history_entry_still_fails_closed"): + project = self.seed_lockout_project( + "unreadable-history-directory-entry", closed_history=False, + ) + (project / "governance" / "history" / "WO-DIRECTORY.md").mkdir() + before = self.tree_snapshot(project) + + result = self.run_inspect("auto", project) + + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertEqual(self.tree_snapshot(project), before) + self.assertIn( + "historical work-order record is not a file", result.stderr + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_uninstall_writwall.py b/tests/test_uninstall_writwall.py new file mode 100644 index 0000000..f222127 --- /dev/null +++ b/tests/test_uninstall_writwall.py @@ -0,0 +1,239 @@ +# SPDX-FileCopyrightText: 2026 HLLMR Ventures LLC +# SPDX-License-Identifier: Apache-2.0 +"""Behavioral recovery tests; no lifecycle activation is required.""" +import contextlib +import io +import importlib.util +import json +import os +from pathlib import Path +import tempfile +import unittest +from unittest import mock + +SOURCE = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location('uninstall', SOURCE / 'scripts/uninstall_writwall.py') +uninstall = importlib.util.module_from_spec(spec) +spec.loader.exec_module(uninstall) + + +class RecoveryTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.base = Path(self.temp.name).resolve() + self.root = self.base / 'project' + self.root.mkdir() + (self.root / '.git').write_text('gitdir: elsewhere') + (self.root / '.claude/hooks').mkdir(parents=True) + self.hook = self.root / uninstall.HOOK + self.hook.write_bytes((SOURCE / 'adapters/claude-code/wo_capability_wall.py').read_bytes()) + self.settings = self.root / uninstall.SETTINGS + self.obj = {'permissions': {'allow': ['Read']}, 'hooks': {'PreToolUse': [{'matcher': '*', 'hooks': [ + {'type': 'command', 'command': sorted(uninstall.COMMANDS)[0], 'timeout': 10}, + {'type': 'command', 'command': 'echo unrelated'}]}]}} + self.save() + + def save(self): + self.settings.write_text(json.dumps(self.obj), encoding='utf-8') + + def apply(self, removals=()): + return uninstall.apply_plan(uninstall.preview(self.root, removals), self.base / 'backup') + + def test_disable_preserves_other_settings_and_restores_exact_bytes(self): + before = self.settings.read_bytes() + (self.root / 'CLAUDE.md').write_text('Writwall legacy instructions') + plan = uninstall.preview(self.root) + self.assertEqual(self.settings.read_bytes(), before) + self.assertEqual(plan['residual_instruction_files'], ['CLAUDE.md']) + result = self.apply() + obj = json.loads(self.settings.read_text()) + self.assertEqual(obj['permissions'], self.obj['permissions']) + self.assertEqual(obj['hooks']['PreToolUse'][0]['hooks'], [{'type': 'command', 'command': 'echo unrelated'}]) + self.assertTrue(self.hook.exists()) + uninstall.restore(result['journal']) + self.assertEqual(self.settings.read_bytes(), before) + + def test_unknown_mixed_and_modified_hook_are_preserved(self): + for command in ['echo ' + sorted(uninstall.COMMANDS)[0], sorted(uninstall.COMMANDS)[0] + ' && echo next']: + self.obj['hooks']['PreToolUse'][0]['hooks'][0]['command'] = command + self.save() + self.assertEqual(uninstall.preview(self.root)['changes'], []) + self.assertTrue(uninstall.preview(self.root)['warnings']) + self.obj['hooks']['PreToolUse'][0]['hooks'][0]['command'] = sorted(uninstall.COMMANDS)[0] + self.save() + self.hook.write_text('modified') + self.assertEqual(uninstall.preview(self.root)['changes'], []) + + def test_crlf_hook_recognized_but_raw_digest_pinned(self): + self.hook.write_bytes(self.hook.read_bytes().replace(b'\r\n', b'\n').replace(b'\n', b'\r\n')) + plan = uninstall.preview(self.root) + self.assertEqual(plan['registrations_removed'], 1) + self.assertEqual(plan['hook_sha256'], uninstall.digest(self.hook.read_bytes())) + + def test_malformed_duplicate_json_and_plan_drift_fail_unchanged(self): + for invalid in (b'{', b'{"hooks":{},"hooks":{}}'): + self.settings.write_bytes(invalid) + with self.assertRaises(ValueError): + uninstall.preview(self.root) + self.assertEqual(self.settings.read_bytes(), invalid) + self.save() + plan = uninstall.preview(self.root) + self.settings.write_bytes(self.settings.read_bytes() + b' ') + with self.assertRaises(uninstall.RecoveryError): + uninstall.apply_plan(plan, self.base / 'backup') + + def test_explicit_document_and_hook_removal_and_restore(self): + (self.root / 'CLAUDE.md').write_text('Writwall instructions') + result = self.apply(['CLAUDE.md', uninstall.HOOK]) + self.assertFalse(self.hook.exists()) + self.assertFalse((self.root / 'CLAUDE.md').exists()) + uninstall.restore(result['journal']) + self.assertTrue(self.hook.exists()) + self.assertEqual((self.root / 'CLAUDE.md').read_text(), 'Writwall instructions') + + def test_restore_detects_drift_and_corrupt_backup(self): + result = self.apply() + changed = self.settings.read_bytes() + self.settings.write_text('new owner settings') + with self.assertRaises(uninstall.RecoveryError): + uninstall.restore(result['journal']) + self.assertEqual(self.settings.read_text(), 'new owner settings') + self.settings.write_bytes(changed) + journal = Path(result['journal']) + (journal.parent / '0.original').write_bytes(b'bad') + with self.assertRaises(uninstall.RecoveryError): + uninstall.restore(journal) + + def test_invalid_paths_backup_and_journal_escape(self): + for rel in ('../outside.md', '/outside.md', 'C:/outside.md', '.git/config', 'src/app.py'): + with self.assertRaises(uninstall.RecoveryError): + uninstall.preview(self.root, [rel]) + with self.assertRaises(uninstall.RecoveryError): + uninstall.apply_plan(uninstall.preview(self.root), self.root / 'backup') + result = self.apply() + journal = Path(result['journal']) + record = json.loads(journal.read_text()) + record['entries'][0]['path'] = '../outside.md' + journal.write_text(json.dumps(record)) + with self.assertRaises(uninstall.RecoveryError): + uninstall.restore(journal) + + def test_hardlinked_target_rejected(self): + try: + os.link(self.settings, self.base / 'linked.json') + except OSError: + self.skipTest('hardlinks unavailable') + with self.assertRaises(uninstall.RecoveryError): + uninstall.preview(self.root) + + def test_descendant_symlink_target_rejected(self): + real = self.base / 'real-settings.json' + real.write_bytes(self.settings.read_bytes()) + self.settings.unlink() + try: + self.settings.symlink_to(real) + except OSError: + self.skipTest('symlink creation unavailable') + before = real.read_bytes() + with self.assertRaises(uninstall.RecoveryError): + uninstall.preview(self.root) + self.assertEqual(real.read_bytes(), before) + + def test_active_order_does_not_control_emergency_exit(self): + (self.root / '.claude/active-wo.txt').write_text('../../malformed') + (self.root / 'governance').mkdir() + (self.root / 'governance/broken.md').write_bytes(b'X' * 100000) + self.assertEqual(uninstall.preview(self.root)['registrations_removed'], 1) + + def test_plan_output_utf8_exclusive_and_external(self): + output = self.base / 'plan.json' + args = ['--project-root', str(self.root), '--plan-output', str(output)] + with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): + self.assertEqual(uninstall.main(args), 0) + self.assertEqual(uninstall.main(args), 2) + self.assertEqual(uninstall.main(['--project-root', str(self.root), '--plan-output', str(self.root / 'plan.json')]), 2) + self.assertEqual(json.loads(output.read_text(encoding='utf-8')), uninstall.preview(self.root)) + + def test_local_settings_surgically_disabled_and_restored(self): + local = self.root / uninstall.LOCAL_SETTINGS + local.write_bytes(self.settings.read_bytes()) + before = local.read_bytes() + self.assertEqual(uninstall.preview(self.root)['registrations_removed'], 2) + result = self.apply() + self.assertNotEqual(local.read_bytes(), before) + uninstall.restore(result['journal']) + self.assertEqual(local.read_bytes(), before) + + def test_nongit_and_explicit_malformed_pointer_removal(self): + (self.root / '.git').unlink() + pointer = self.root / uninstall.POINTER + pointer.write_bytes(b'\xff../../broken') + result = self.apply([uninstall.POINTER]) + self.assertFalse(pointer.exists()) + uninstall.restore(result['journal']) + self.assertEqual(pointer.read_bytes(), b'\xff../../broken') + + def test_custom_command_requires_exact_explicit_selection(self): + custom = 'custom wrapper wo_capability_wall.py --flag' + self.obj['hooks']['PreToolUse'][0]['hooks'][0]['command'] = custom + self.save() + self.assertEqual(uninstall.preview(self.root)['registrations_removed'], 0) + plan = uninstall.preview(self.root, remove_hook_commands=[custom]) + self.assertEqual(plan['registrations_removed'], 1) + result = uninstall.apply_plan(plan, self.base / 'backup') + uninstall.restore(result['journal']) + self.assertIn(custom, self.settings.read_text()) + + def test_windows_dangerous_names_rejected_on_every_host(self): + for rel in ('.GIT/private.md', '.git./private.md', 'docs./file.md', + 'docs /file.md', 'CON.md', 'com1.md', 'LPT9/file.md', 'aux.txt/file.md'): + with self.subTest(rel=rel), self.assertRaises(uninstall.RecoveryError): + uninstall.relative_target(self.root, rel) + + def test_case_aliases_rejected_before_preview_mutation(self): + (self.root / 'Doc.md').write_text('Writwall record') + (self.root / 'doc.md').write_text('Writwall record') + with self.assertRaises(uninstall.RecoveryError): + uninstall.preview(self.root, ['Doc.md', 'doc.md']) + self.assertTrue((self.root / 'Doc.md').exists()) + + def test_restore_rejects_aliases_and_git_case_before_writes(self): + (self.root / 'Doc.md').write_text('Writwall record') + result = self.apply(['Doc.md']) + journal = Path(result['journal']) + record = json.loads(journal.read_text()) + duplicate = dict(record['entries'][-1]) + duplicate['path'] = 'doc.md' + record['entries'].append(duplicate) + journal.write_text(json.dumps(record)) + with self.assertRaises(uninstall.RecoveryError): + uninstall.restore(journal) + self.assertFalse((self.root / 'Doc.md').exists()) + record['entries'][-1]['path'] = '.GIT/private.md' + journal.write_text(json.dumps(record)) + with self.assertRaises(uninstall.RecoveryError): + uninstall.restore(journal) + + def test_second_mutation_failure_returns_recoverable_journal(self): + before = self.settings.read_bytes() + hook_before = self.hook.read_bytes() + original_unlink = Path.unlink + def fail_hook(path, *args, **kwargs): + if path == self.hook: + raise OSError('injected second mutation failure') + return original_unlink(path, *args, **kwargs) + with mock.patch.object(Path, 'unlink', fail_hook): + with self.assertRaisesRegex(uninstall.RecoveryError, 'recovery journal:') as raised: + self.apply([uninstall.HOOK]) + journal = Path(str(raised.exception).split('recovery journal: ', 1)[1]) + self.assertTrue(journal.is_file()) + self.assertNotEqual(self.settings.read_bytes(), before) + self.assertEqual(self.hook.read_bytes(), hook_before) + uninstall.restore(journal) + self.assertEqual(self.settings.read_bytes(), before) + self.assertEqual(self.hook.read_bytes(), hook_before) + + +if __name__ == '__main__': + unittest.main() diff --git a/writwall_cli/__main__.py b/writwall_cli/__main__.py index 76ae39b..053de67 100644 --- a/writwall_cli/__main__.py +++ b/writwall_cli/__main__.py @@ -17,6 +17,11 @@ def build_parser() -> argparse.ArgumentParser: description="Start with an idea and prepare a governed project handoff.", help="Start with an idea", ) + commands.add_parser( + "uninstall", + description="Preview or apply an Owner-operated emergency exit.", + help="Disable Writwall safely with backup and restore", + ) inspect = commands.add_parser( "inspect", description="Inspect a project and print a read-only role handoff.", @@ -55,6 +60,9 @@ def main(argv: Sequence[str] | None = None) -> int: if arguments[0] == "privacy": from scripts.privacy_screen import main as privacy_main return privacy_main(arguments[1:]) + if arguments[0] == "uninstall": + from scripts.uninstall_writwall import main as uninstall_main + return uninstall_main(arguments[1:]) if arguments[0] == "inspect": from writwall_cli.coordinator import inspect return inspect(arguments[1:])