From f22fc2b25a54d7c8528fa76738a5191c126a68fb Mon Sep 17 00:00:00 2001 From: HLLMR Date: Sat, 29 Aug 2026 22:11:19 -0500 Subject: [PATCH] Add Writwall day-zero coordinator --- ADOPTING.md | 20 + LICENSE-MAP.md | 2 +- PROJECTION-MANIFEST.sha256 | 31 +- PROJECTION-PROVENANCE.md | 6 +- README.md | 34 +- REUSE.toml | 1 + START-HERE.md | 48 +- checks/check_distribution.py | 23 +- docs/day-zero-coordinator.md | 110 +++++ governance/LOG.md | 33 ++ governance/STATE.md | 15 +- identity/legacy-references.json | 12 +- projection/public-files.txt | 3 + scripts/start_writwall.py | 765 +++++++++++++++++++++++++++++++ skills/writwall-adopt/SKILL.md | 14 + tests/test_check_distribution.py | 31 ++ tests/test_distribution.py | 39 ++ tests/test_start_writwall.py | 422 +++++++++++++++++ 18 files changed, 1554 insertions(+), 55 deletions(-) create mode 100644 docs/day-zero-coordinator.md create mode 100644 scripts/start_writwall.py create mode 100644 tests/test_start_writwall.py diff --git a/ADOPTING.md b/ADOPTING.md index b967f6b..c140abf 100644 --- a/ADOPTING.md +++ b/ADOPTING.md @@ -40,6 +40,23 @@ mechanical route below. In particular, make the self-contained adoption bundle and these instructions local **before the wall is registered**. A correctly locked session may deny the network request that would otherwise retrieve them. +For a first adoption, the default is the day-zero coordinator: + +```text +# Windows +py -3 scripts/start_writwall.py --project-root C:\path\to\your-project + +# macOS or Linux +python3 scripts/start_writwall.py --project-root /path/to/your-project +``` + +It classifies the target from repository bytes, copies the complete skill bundle +into a temporary `.writwall-bootstrap/` directory, and emits the exact next +prompt. It is create-only bootstrap tooling, not an authority or installer. +Contradictory active state stops before output. Full interface and external- +Operator packet behavior are documented in +[`docs/day-zero-coordinator.md`](docs/day-zero-coordinator.md). + | Route | Use when | Tooling | What it produces | |---|---|---|---| | A. Chat prompt | You want to think it through with a model before touching the repo, or your project has an existing document corpus that needs mapping | Any capable chat model | Draft DR-001, draft adoption mapping, draft charter kill list, a checklist of manual steps | @@ -48,6 +65,9 @@ locked session may deny the network request that would otherwise retrieve them. Routes combine. A common path for an existing project is A (mapping conversation) followed by B (mechanical bootstrap) followed by the Owner steps in section 5. +The coordinator selects among these routes; it does not replace them. Its +handoff is temporary and must be removed before the adoption commit. + --- ## 2. Route A: the adoption prompt diff --git a/LICENSE-MAP.md b/LICENSE-MAP.md index ba6b120..cfb1208 100644 --- a/LICENSE-MAP.md +++ b/LICENSE-MAP.md @@ -7,7 +7,7 @@ the root `LICENSE` for every path assigned differently below. | Scope | License | SPDX identifier | |---|---|---| -| `DOCTRINE.md`, `README.md`, `START-HERE.md`, `ADOPTING.md`, `SELF-HOSTING.md`, `CLAUDE.md`, `migration-guides/**`, `decisions/**`, `governance/**`, `archive/**`, `examples/**`, `identity/**`, `docs/assets/**`, `docs/agents/**`, `docs/name-clearance.md`, `docs/identity-migration.md`, `.github/ISSUE_TEMPLATE/**`, `.github/pull_request_template.md`, and other prose or public visual assets | Creative Commons Attribution 4.0 International | `CC-BY-4.0` | +| `DOCTRINE.md`, `README.md`, `START-HERE.md`, `ADOPTING.md`, `SELF-HOSTING.md`, `CLAUDE.md`, `migration-guides/**`, `decisions/**`, `governance/**`, `archive/**`, `examples/**`, `identity/**`, `docs/assets/**`, `docs/agents/**`, `docs/day-zero-coordinator.md`, `docs/name-clearance.md`, `docs/identity-migration.md`, `.github/ISSUE_TEMPLATE/**`, `.github/pull_request_template.md`, and other prose or public visual assets | Creative Commons Attribution 4.0 International | `CC-BY-4.0` | | `templates/**` | Creative Commons CC0 1.0 Universal | `CC0-1.0` | | `adapters/**`, `.claude/hooks/wo_capability_wall.py`, and `init.sh` | MIT No Attribution | `MIT-0` | | `scripts/**`, `checks/**`, `tests/**`, and `.github/workflows/**` | Apache License 2.0 | `Apache-2.0` | diff --git a/PROJECTION-MANIFEST.sha256 b/PROJECTION-MANIFEST.sha256 index e6bb339..0aa3196 100644 --- a/PROJECTION-MANIFEST.sha256 +++ b/PROJECTION-MANIFEST.sha256 @@ -5,28 +5,28 @@ b2e36dfcfc6eb31570c9340640bcd73abc62f57c80b4794abf36e3d3e89ab34f .github/depend 9e90d43615b02a265b08692ef7a1c00a37477a5c6b1e8233a8fc7bedefaedea6 .github/pull_request_template.md 419af9d8c4b603dd0c48b9897894a836125f9de10290f8af026a3b4e5565987d .github/workflows/ci.yml e544abe8ffd83c81c7b002cbd2e552f9d56f226ea20e1e0722c5d1bdec914fe0 .gitignore -fb3be6732ba55f0684205b70e04c1f2d7e27a49b503ed847e5fd7603300d5ac4 ADOPTING.md +8f238796fff926a4fffe33080b4ff6face299ae84d9e60da532d71257465f00b ADOPTING.md 1179c999034f4ec1c1d44c1946bd2955c4625905e80767abe760c8c3ab01c493 CLAUDE.md e32e0cc74d7f5992c6ce87d68bd17fca5f158570a00fc05c695c3f2e508d37d8 CONTRIBUTING.md 664196054cd98585105be457afa09c788a482416ccb48a87bb269b2156e49ae6 DOCTRINE.md 9ba9550ad48438d0836ddab3da480b3b69ffa0aac7b7878b5a0039e7ab429411 LICENSE -d00edb1fc3074e38b37e25d137ca3b7591e3506621d16a42a16963d0bb531d13 LICENSE-MAP.md +65b5178d1d9cd38398141d6c3cce98afbef3bed01078649dfc2cc0e13442f83b LICENSE-MAP.md c274f80372d90c012937370f0e1f15087d22e308ef98b27cea5dc0d2d088366c LICENSES/Apache-2.0.txt 9ba9550ad48438d0836ddab3da480b3b69ffa0aac7b7878b5a0039e7ab429411 LICENSES/CC-BY-4.0.txt a2010f343487d3f7618affe54f789f5487602331c0a8d03f49e9a7c547cf0499 LICENSES/CC0-1.0.txt 59746d6285ffa44bfc7ecada352aa5d6a20dc8eab418a60ce091cc739012c135 LICENSES/MIT-0.txt 35e6d37b7c5fa0c1fc872315cbd362cd24bfa41e1b7dc3019fbcd31e99350f51 NAMING.md -bd4ecb88f14a7201ff0dec20a3233dde8aa78ab42ff680909bc93a9c8d3b336e PROJECTION-PROVENANCE.md +bd0dc7b20da29af00b98aa0180d6c0fecd43552180f2578e687c1816e53e14f9 PROJECTION-PROVENANCE.md 20dd826e70636087c9f7758402990d4b0f13a3b64cc685d8f783afa16e092caf PUBLICATION.md -4b01bc93641761adbaf3b28819abdd347f3a8ea7e557fa3fe61382027451e21c README.md -8692caf4284aa02b2fca277a9fa0285ffcb18023e59b89ce31761f5f1c2011e0 REUSE.toml +6bb209548bee395394c53b8a04cec1ed558730b1fb6af6c4f60334ab1e4a9a6d README.md +5c91b3001237004226fcb3869cf9569f6d4987bb9f7f2c5704c9909a9c627e59 REUSE.toml ab75b39490b4db4e203f5b23b480a1c998d87cf07d760cb787cb260778b21d0a SECURITY.md 6a51c1211cc675599634d144ca24a705ec1696f84640a6464b14efb1d6c3a629 SELF-HOSTING.md -7969a1e8b889dcde7789a079aee017a8312dcaa656f3539d6a57717a2402e5b5 START-HERE.md +7f916275e4876652ecbc181faf3394eceb182634842365dbff91edfa6751dc96 START-HERE.md a4723267565778de85ae6f00f99bb903eab625e98917389fe1821a463d3d867d adapters/claude-code/README.md aeb7f81d139e7ffa6de9a1782444b99eb6d549ac1c3a8b9c0f8bcb9c6addfa6d adapters/claude-code/SECURITY.md dd29af2a39d25e0270ad9acc23ee912f81e39c674e1179759f4a3010c6a0c1a0 adapters/claude-code/wo_capability_wall.py -1e988ba28767063ff0d93969730321b4d7942f992edb3bbc177fbd2124c29f1b checks/check_distribution.py +da0b19de32aa43c9b97b05d74681a306d1b3e41c0e3f4de08aa2be85c4827241 checks/check_distribution.py 60fe377dac32b8d1697f859371ef40d26ed4e695fdceeda6d29ec0318d539504 checks/check_identity.py 30986c40ff7c9b29e2fba39ec04c18af3c1c351490410bd532a8391bcb92ed11 checks/check_licenses.py e843892f24360174620fa02f3adf142a5eb5e2aac4bbfea786648f8b49375192 checks/check_name_clearance.py @@ -45,6 +45,7 @@ fa88788242d920999b6e6737ea60b03dfe3f9ba2e0d90386b6bbcfeb6acd0509 docs/agents/do 55816569390363947ecc7735d7de13a3f59b8dae51f92383967e130b3a56d2a6 docs/assets/writwall-og.svg 0a5259d80265765aee16a421546e44458a1aabeecfa8c7f7dea8aead6a79655b docs/assets/writwall-readme-banner-0a5259d8.png ad0fb4f671b8da9e3ab9720af7b39ac9c93201e6131c1df996e090a2bb2acc8a docs/assets/writwall-readme-banner.svg +78f1d4bb82cea5a048ddf867f54e67eb64bc71e2e3c222818a685074f75f4b57 docs/day-zero-coordinator.md e1214e3e6018642809339249bb091a6fd754847b4f77c4bc7a39c5c87e6769cc docs/identity-migration.md 4d54cd53db8c165b40af7eb11b97a7f4faf5f23479c0efc8238e5d463b159008 docs/name-clearance.md 55716ad256ad76dd355d10ab872ffcd29b03fbf9651763b21351e813ef89d0c5 examples/README.md @@ -57,10 +58,10 @@ d2c5a8ca21edf842dfd17a83862024afa0a92349abf693a60e55ce454c8d78fa examples/name- 30cdb11fbeb2fd9bbf4048255331ccbbdd6e516fae5adfa5317af00ce53607c9 governance/ADOPTION-MAPPING.md 08b235351ab7799715b1e2df4fa3dd9fa88bb85084c8aade4d01039bf255b489 governance/LOG-denials-probes.md 50784b173c90c4fd22572306429187c8a7e22614f4b9ad649a3005647467c7fa governance/LOG-denials.jsonl -37f2db9b6959ca756c57b2f6e66fc7accb33c0323fa2d4be5d5cd94de3f0f80f governance/LOG.md +d1553b059c3b35b5c540f90ee1aecbcb6cff635926a5d90bcbbfb75866dd9b6c governance/LOG.md 4421b3b6d1ddbfaf028ad8df19b8b27424db51d36278f249975bc76ab849673e governance/PLAN.md 2f534d0b74d644686344128e7cc6627ee965e6956d8d1e0b150ee4ca58f56879 governance/ROUTING.md -8f07eebcfd887ff6ad0900a7111e719ec51ac972565cc88f1bf6f9c1c6c73a30 governance/STATE.md +0d878b1813dcb3e7588193b0f2d9e4fafa645db4b726261b5796f8caf74db609 governance/STATE.md e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 governance/archive/.gitkeep e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 governance/briefs/.gitkeep e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 governance/decisions/.gitkeep @@ -77,17 +78,18 @@ b567ce0c0867464328e81774d888f6491fa66b68ac73be01f993e5c4c66d3ed8 governance/tem d355e46f978f17de8824af805e05124e0f20b1c072523b518422044f88c6f079 governance/templates/D-adoption-record.md 2b586efadab716a59fcafb74312a45a05401a4787fee6ae18cb5c9dd14ef3a09 governance/templates/E-adoption-mapping.md e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 governance/work-orders/.gitkeep -d78aa2546ec994415ce40602fd889f89b3c6d84696a3e747989fc5b0271444bb identity/legacy-references.json +547b1cce3b6e12cfd8a7bf3b9bce56f41d47193f1662a91531b0f3c19c5c5432 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 -0b20940fecd22683586c5947b129e0203ed1534692993e40068f6305bfef8106 projection/public-files.txt +bd649302c60a6fb8d255c8b0d157cc1fbc6e95b6f626656d77d5df9abf71ea3f projection/public-files.txt 455ca1ab3c9e7e78afbb9946e13b94497ba24003ab6f411ea96cce26d4ecc39e scripts/build_distribution.py 762973ced40b5746a017eac52e4ce58180419f20c95bd861829cb7332f76fcd2 scripts/build_public_projection.py 3cf88f936599e0e84bc2368bc0503a39b9f96e47b473e09b26569e5c3c9edbd9 scripts/collect_name_clearance.py +1f06adfbd54410d13b518019db825c042017bca8e3df93b87130152f166e3f10 scripts/start_writwall.py 136fbbf25121905bd39828f7f0fe01c688908553f523e295f04f514552ab78aa skills/writwall-adopt/LICENSE-MAP.md -00731110a4f72a47caf6d680f1c8ccbcc232e8b5cf5384af6a2f837a4120a5df skills/writwall-adopt/SKILL.md +df64fb9788240618693f7e32aad3a73b6d724849a1d889569a961c66287604a4 skills/writwall-adopt/SKILL.md a4723267565778de85ae6f00f99bb903eab625e98917389fe1821a463d3d867d skills/writwall-adopt/assets/adapters/claude-code/README.md dd29af2a39d25e0270ad9acc23ee912f81e39c674e1179759f4a3010c6a0c1a0 skills/writwall-adopt/assets/adapters/claude-code/wo_capability_wall.py bb54d108d1dff56291169624d26eac4f44db00ebf98e38bf7e6d56cd9b8d9647 skills/writwall-adopt/assets/checks/check_work_order_dispatch.py @@ -105,12 +107,13 @@ b567ce0c0867464328e81774d888f6491fa66b68ac73be01f993e5c4c66d3ed8 templates/A-ch 5bfaa890ffddd423644428606753bc2e6562e3e5d67c1dc522172710708a4bf0 templates/C-owner-brief.md d355e46f978f17de8824af805e05124e0f20b1c072523b518422044f88c6f079 templates/D-adoption-record.md 2b586efadab716a59fcafb74312a45a05401a4787fee6ae18cb5c9dd14ef3a09 templates/E-adoption-mapping.md -5dca1b9e7e1059a631cdd9b5da02d6cdc5b2a6ed9cbff48faff37c1d81adec49 tests/test_check_distribution.py +f555e5f2593043b4b897b1dcaa038dbce7b09890a37000e046e2f2a66f3708c9 tests/test_check_distribution.py b046f2eea794070194294a33f2914e627eed384e63fccffc2ac46693db2a968c tests/test_check_licenses.py 9a106ff5182b4a15713575de42e90b0dc5cdeebcb17ba08d97522a4c9aa6b2fa tests/test_check_work_order_dispatch.py -05698dd8ad9b22d5b40f8ab3bd733afcfc0302ea31a8769eafc80305c321b1b5 tests/test_distribution.py +9e09d9c35634505bd8ad9291812cce2aaa93a1948f8ff52e9073dc6c38462318 tests/test_distribution.py e150a2f988a4b0beac5f70644f55f5e185a8aa575e988a19642bafabc0f07775 tests/test_identity_migration.py 011d79618848880de8600b11bcedbdd6ba8b68124ddc3ae3e522288639c2498c tests/test_init_sh.py 96c255d84e37b8884cde769897e763776b81027080c2308c33dc5e4b8df4a4bf tests/test_name_clearance.py 2636e1f2c1f23c4d234b7b8c8886c3922cd94005bd20aed05c3846cd0aabe891 tests/test_public_projection.py +72d30a1c670af5019ef657af4dc6fc7a347fa15997abc576e89bb50540b5710c tests/test_start_writwall.py 0684c04067eb95eadc9f72ab126d8662b4a5e2005c80b2dea174075a6140eebc tests/test_wo_capability_wall.py diff --git a/PROJECTION-PROVENANCE.md b/PROJECTION-PROVENANCE.md index 0d9c02c..c300740 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: `3d5dc47f5c0c046a384dedc17fdc1a673fcd05d1` -- Source commit time: `2026-08-29T18:03:18-05:00` -- Projection allowlist SHA-256: `0b20940fecd22683586c5947b129e0203ed1534692993e40068f6305bfef8106` +- Source commit: `10fbff507409e1b549db69ecb462b7cdeb4e66b4` +- Source commit time: `2026-08-29T21:39:04-05:00` +- Projection allowlist SHA-256: `bd649302c60a6fb8d255c8b0d157cc1fbc6e95b6f626656d77d5df9abf71ea3f` ## Legacy identifier inventory diff --git a/README.md b/README.md index 9a9519a..b384a60 100644 --- a/README.md +++ b/README.md @@ -93,23 +93,31 @@ actually blocks the current session before real work begins. ## Try it in five minutes -If you are new to Writwall, begin with [START-HERE.md](START-HERE.md). It tells -you which role to engage, whether that agent belongs inside or outside the IDE, -and gives you the exact first prompt. The create-only scaffolder is useful only -after you understand that it does not complete adoption: +If you are new to Writwall, run the day-zero coordinator from this clean source +distribution. It inspects the target before assigning a role, makes the complete +adoption bundle local, and writes the exact next prompt without installing the +wall or claiming adoption: ```text -./init.sh /absolute/path/to/your-project -python3 /absolute/path/to/your-project/checks/check_work_order_dispatch.py --lockout +# Windows +py -3 scripts/start_writwall.py --project-root C:\path\to\your-project + +# macOS or Linux +python3 scripts/start_writwall.py --project-root /path/to/your-project ``` -This creates the governance directories, copies the templates, and installs -the pre-dispatch validator. If the target already has a `.claude/` directory, -it also copies the adapter file; it **does not register, activate, or birth-test** -that hook, inventory existing project authority, ratify adoption, or make a commit. -Read [ADOPTING.md](ADOPTING.md) next for the complete adoption sequence. Make -the bundled adoption skill local before registering a wall that may deny the -agent's network access. +The command asks one question at a time and creates only +`.writwall-bootstrap/` in the target. Read its `HANDOFF.md`, open the named +agent in the named location, and paste the supplied prompt. Do not enter +passwords, API tokens, private keys, mailbox contents, DNS values, or other +secrets; intake is stored as local plain text. See the +[coordinator reference](docs/day-zero-coordinator.md), or use +[START-HERE.md](START-HERE.md) for the manual and recovery routes. +The coordinator does not register, activate, or birth-test the wall. + +The lower-level `init.sh` scaffolder remains available after you understand +the adoption sequence. It creates directories and template copies but does not +inventory authority, register or birth-test a hook, ratify adoption, or commit. If the project does not yet have a settled public identity, stop before naming packages, repositories, domains, or launch assets. Run the evidence-producing diff --git a/REUSE.toml b/REUSE.toml index 6d84d91..c42039f 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -15,6 +15,7 @@ path = [ "decisions/**", "docs/assets/**", "docs/agents/**", + "docs/day-zero-coordinator.md", "docs/identity-migration.md", "docs/name-clearance.md", "examples/**", diff --git a/START-HERE.md b/START-HERE.md index f450b18..f283e1e 100644 --- a/START-HERE.md +++ b/START-HERE.md @@ -59,16 +59,44 @@ names, repository slugs, domains, logos, or launch copy. The coordinator may collect evidence, but the Owner chooses the identity; unavailable sources are not clear results. -1. Obtain the Writwall source distribution. Do not unpack it over your project. -2. Copy the complete `skills/writwall-adopt/` directory into the target - provider's temporary skill location. For Claude Code, use - `.claude/skills/writwall-adopt/`. -3. Confirm the bundle is locally readable. It contains the Doctrine, adapter, - checker, templates, and migration guides it needs. -4. Start the adoption coordinator. The bundle stays available until recorder - closeout no longer needs it and is removed before the adoption commit. -5. Register and birth-test the wall only through the exact lifecycle the - coordinator prepares and you ratify. +1. Obtain a clean Writwall source distribution. Do not unpack it over your + project. +2. From that distribution, run one command: + + ```text + # Windows + py -3 scripts/start_writwall.py --project-root C:\path\to\your-project + + # macOS or Linux + python3 scripts/start_writwall.py --project-root /path/to/your-project + ``` + +3. If you choose to track Owner active minutes, start the timer when the first + question tells you to; do not reconstruct time later. Answer one question at + a time without entering secrets. You may point it at an existing brief. The + command observes actual repository lifecycle state and creates only + `/.writwall-bootstrap/`. +4. Open its `HANDOFF.md`. Start the agent and location it names and paste the + exact prompt. The complete local `writwall-adopt` bundle is already beside + the handoff. +5. Keep that temporary directory until recorder closeout no longer needs it; + remove it before the adoption commit. Register and birth-test the wall only + through the exact lifecycle the coordinator prepares and you ratify. + +The command does not install Writwall, interpret intake as ratified intent, +create an activation pointer, contact an external system, or replace the +Owner-Agent. It stops on contradictory state instead of guessing from prior +chat. See [`docs/day-zero-coordinator.md`](docs/day-zero-coordinator.md) for +the complete contract. + +### Manual fallback + +If Python is unavailable, copy the complete `skills/writwall-adopt/` directory +into a temporary project-local location before registering any wall, confirm +it is readable, then use the prompt below. For Claude Code, a temporary +`.claude/skills/writwall-adopt/` location is supported. Keep the bundle until +the final authorized recorder action that needs it and remove it before the +adoption commit. Paste this first: diff --git a/checks/check_distribution.py b/checks/check_distribution.py index 15082ee..783ee8d 100644 --- a/checks/check_distribution.py +++ b/checks/check_distribution.py @@ -81,6 +81,7 @@ def machine_path_occurs(text: str, needle: str) -> bool: "decisions/README.md", "decisions/DR-001.md", "decisions/DR-003.md", + "docs/day-zero-coordinator.md", "skills/writwall-adopt/LICENSE-MAP.md", ) @@ -178,6 +179,7 @@ def machine_path_occurs(text: str, needle: str) -> bool: "docs/agents/domain.md", "docs/agents/issue-tracker.md", "docs/agents/triage-labels.md", + "docs/day-zero-coordinator.md", "docs/name-clearance.md", "docs/identity-migration.md", "examples/README.md", @@ -193,6 +195,7 @@ def machine_path_occurs(text: str, needle: str) -> bool: "scripts/build_distribution.py", "scripts/build_public_projection.py", "scripts/collect_name_clearance.py", + "scripts/start_writwall.py", "skills/writwall-adopt/SKILL.md", "skills/writwall-adopt/LICENSE-MAP.md", "tests/test_init_sh.py", @@ -203,6 +206,7 @@ def machine_path_occurs(text: str, needle: str) -> bool: "tests/test_wo_capability_wall.py", "tests/test_check_work_order_dispatch.py", "tests/test_public_projection.py", + "tests/test_start_writwall.py", "projection/public-files.txt", *[f"templates/{name}" for name in TEMPLATE_FILES.values()], *[str(p.relative_to(REPO_ROOT)).replace("\\", "/") for p in BUNDLE_COPIES], @@ -1098,6 +1102,7 @@ def check_onboarding_contract(failures: Failures) -> None: "README.md": REPO_ROOT / "README.md", "START-HERE.md": REPO_ROOT / "START-HERE.md", "ADOPTING.md": REPO_ROOT / "ADOPTING.md", + "docs/day-zero-coordinator.md": REPO_ROOT / "docs" / "day-zero-coordinator.md", "skills/writwall-adopt/SKILL.md": SKILL / "SKILL.md", "adapters/claude-code/README.md": ADAPTER_README, "init.sh": REPO_ROOT / "init.sh", @@ -1111,8 +1116,9 @@ def check_onboarding_contract(failures: Failures) -> None: "README.md": ( "START-HERE.md", "docs/name-clearance.md", - "does not register, activate, or birth-test", - "final recorder action", + "scripts/start_writwall.py", + ".writwall-bootstrap/", + "wall or claiming adoption", ), "START-HERE.md": ( "Small project", @@ -1124,6 +1130,8 @@ def check_onboarding_contract(failures: Failures) -> None: "fresh Reviewer", "chat exchange alone is not lifecycle authorization", "docs/name-clearance.md", + "scripts/start_writwall.py", + ".writwall-bootstrap/", ), "ADOPTING.md": ( "before the wall is registered", @@ -1133,6 +1141,15 @@ def check_onboarding_contract(failures: Failures) -> None: "both doctrinal birth-test levels", "does not by itself block adoption", "durable lifecycle authorization", + "scripts/start_writwall.py", + "create-only bootstrap tooling", + ), + "docs/day-zero-coordinator.md": ( + "single human entry point", + "repository bytes", + ".writwall-bootstrap/", + "confers no authority", + "NOT REPORTED", ), "skills/writwall-adopt/SKILL.md": ( "before the wall is registered", @@ -1142,6 +1159,8 @@ def check_onboarding_contract(failures: Failures) -> None: "chat alone is not the", "portable Windows-and-POSIX claim", "PROJECTION-MANIFEST.sha256", + ".writwall-bootstrap/HANDOFF.md", + "Never infer an active work order from prior chat", ), "adapters/claude-code/README.md": ( "minimal provider profile", diff --git a/docs/day-zero-coordinator.md b/docs/day-zero-coordinator.md new file mode 100644 index 0000000..3f7ac7b --- /dev/null +++ b/docs/day-zero-coordinator.md @@ -0,0 +1,110 @@ +# Day-zero coordinator + +The day-zero coordinator is Writwall's single human entry point. It is a +standard-library Python command that runs from a clean Writwall source +distribution and prepares a temporary handoff inside the target project. + +It is intentionally smaller than an AI agent. It does not interpret intent, +install a capability wall, ratify adoption, activate a work order, contact a +provider, or mutate production. It makes the complete adoption bundle local, +records unratified intake, observes repository lifecycle state, and tells the +human which agent to open next and what to paste. + +## Run it + +From the unpacked Writwall source directory, not from inside an overlaid target: + +```text +# Windows +py -3 scripts/start_writwall.py --project-root C:\path\to\your-project + +# macOS or Linux +python3 scripts/start_writwall.py --project-root /path/to/your-project +``` + +The command asks one question at a time. Its first question offers Owner-time +capture and tells you to start the timer before answering. It then offers an +existing brief-file path before asking you to restate the project purpose and +recommends the smallest credible role split before external functions are +assigned. Do not enter passwords, API tokens, private keys, mailbox contents, +DNS record values, or other secrets. The answers are stored as ordinary local +text. + +For deterministic automation or testing, use `--non-interactive` together +with `--project-name`, either `--purpose` or `--brief-file`, `--agent`, +`--location`, `--environment`, `--owner-time yes|no`, and +`--confirm-no-secrets`. Repeat +`--external-operator` for each separately governed external function. Run +`--help` for the complete interface. + +`--scenario dns-mail-migration` provides a sanitized five-packet starting +shape: move authoritative DNS for eight domains first, verify it, then change +mail routing, while historical mailbox inventory/cleanup/migration remains a +separate function. It supplies no provider, domain, account, record, or mailbox +value. + +## What it observes + +The coordinator reads the target repository before assigning a role: + +- no Writwall markers: clean/new bootstrap; +- incomplete Writwall-shaped material: recovery coordinator; +- adopted repository with no pointer: lockout and Dispatcher route; +- a valid pointer to an exact `status: ACTIVE` order: Implementer route; +- closed work in history with no pointer: retired lockout, never an active- + Implementer continuation; +- malformed, missing, retired, or contradictory active state: stop without + generating a handoff. + +Repository bytes are authoritative for this observation. A prior chat message +or remembered work-order name is not lifecycle state. + +## What it creates + +The command refuses to run if `/.writwall-bootstrap/` already exists, +including a dangling symlink, junction, or reparse entry. It also rejects +linklike lifecycle paths and stops when more than one ACTIVE work order exists. +It builds a complete temporary sibling on the same filesystem and publishes it +with one atomic directory rename; a caught pre-publication failure verifies +that the stage is gone and leaves no target output. +On success that temporary, create-only directory contains: + +- `HANDOFF.md`: observed state, role routing, exact next prompt, authority + boundaries, and optional Owner-time instructions; +- `intake.json`: machine-readable, explicitly unratified answers; +- `writwall-adopt/`: the complete local adoption skill bundle; +- `operations/*.md`: inert packet scaffolds for separately named external + Operator functions. + +The bundle remains local until the authorized recorder no longer needs it and +is removed before the adoption commit. The command does not modify an existing +project file, register a hook, create `.claude/active-wo.txt`, or claim the +project has adopted Writwall. + +## Roles and external systems + +The human remains Owner. The Owner-Agent coordinates, drafts, routes, records +ratified decisions, and performs exactly authorized lifecycle mechanics. A +repository Operator works under one active work order. An infrastructure, +DNS, mail, deployment, or other external Operator receives a bounded packet +and returns evidence; it remains outside the repository wall unless it edits +repository bytes. A fresh Reviewer checks the relevant order, result, report, +and returned evidence. + +The Owner-Agent keeps the proverbial keys—authority and routing—not literal +passwords or cryptographic material. External packets separate preconditions, +permitted and prohibited actions, verification, rollback, evidence, and +credential handling. Blank packet fields authorize nothing. +An operation-packet scaffold confers no authority by itself. + +## Owner active minutes + +If enabled, capture starts when the first intake question appears and stops +when the coordinator returns the ratifiable adoption packet or next-work-order +candidate. Count human reading, deciding, responding, authentication, and +unavoidable UI work. Exclude agent execution and waiting. If capture is +declined, the handoff records `NOT REPORTED`; no later agent reconstructs it. + +The manual routes remain in [`START-HERE.md`](../START-HERE.md) and +[`ADOPTING.md`](../ADOPTING.md) for environments without a supported Python +runtime or for recovery that requires an external coordinator. diff --git a/governance/LOG.md b/governance/LOG.md index d1d660b..3eac13a 100644 --- a/governance/LOG.md +++ b/governance/LOG.md @@ -563,6 +563,39 @@ acceptance. The Owner authorized ordinary closeout/private commit and the post-closeout issue #4 projection PR, with merge only after live `CI required` passes; WO-PL-040 remains inactive. +### Post-pilot WO-PL-040 completed record + +WO-PL-040 is post-pilot and does not add an eleventh metrics row or change the +accepted ten-order aggregate. The Owner accepted it on 2026-08-29 and reported +active minutes **NOT REPORTED**. + +The order adds the standard-library `writwall start` bootstrap kernel. It +classifies actual lifecycle bytes before routing, makes the complete adoption +bundle local, records unratified intake, emits an exact next-agent prompt, and +separates repository and external-Operator packets. The sanitized reference +walkthrough preserves eight-domain DNS authority migration, DNS-before-mail +ordering, and separate historical-mail inventory, cleanup, and migration +without accessing any live provider, account, record, mailbox, credential, +server, website, or other project. + +Three fresh reviews materially improved the result. The first found +contradictory ACTIVE-state, link/path, scenario, intake, and coverage defects. +The second verified those corrections and found non-atomic publication. The +final implementation uses a complete same-filesystem sibling stage and one +atomic rename with verified failure cleanup; fault injection proves no target +output or stage survives a caught publication failure. The final fresh review +returned **ACCEPT WITH NON-BLOCKING POLISH**. + +The atomic-final suite passed 687 tests with two skips. Two independent +120-file projections were checker-clean and byte-identical at complete +projection-manifest SHA-256 +`F08B7CB5ED00006B8F525BA210D525E556FA135084401AD72CBE6FC1513A8D1F`. +The original command-mediated archive/history fixture-read departure and the +identity-ledger grant omission remain disclosed under Owner-approved Amendment +1. The denial log remained 310 records and byte-unchanged. No external system, +production operation, release, tag, or successor implementation occurred +before acceptance. + --- ## Column definitions diff --git a/governance/STATE.md b/governance/STATE.md index 2454ebc..52efe78 100644 --- a/governance/STATE.md +++ b/governance/STATE.md @@ -7,10 +7,11 @@ records the repository state observed after the Plumbline 0.8 public release, accepted WO-PL-034 public-front-door polish, accepted WO-PL-035 onboarding repair, accepted WO-PL-036 name-clearance and replacement selection, and accepted WO-PL-037 controlled migration to the current **Writwall** identity, -accepted WO-PL-038 wall-glyph identity correction, and accepted WO-PL-039 -cache-safe public-identity and repository hardening. +accepted WO-PL-038 wall-glyph identity correction, accepted WO-PL-039 +cache-safe public-identity and repository hardening, and accepted WO-PL-040 +executable day-zero coordination. -**Derived:** 2026-08-28 from the ten accepted pilot records, the Doctrine 9.3.1 +**Derived:** 2026-08-29 from the ten accepted pilot records, the Doctrine 9.3.1 fresh-agent evaluation, ratified DR-002 and project-migration DR-003, and accepted WO-PL-017 through WO-PL-023 and WO-PL-025 through WO-PL-033 records, the WO-PL-024 sequencing recovery, and the verified public-release event. @@ -22,7 +23,8 @@ WO-PL-026 complete and RFI-22 closed; WO-PL-027 through WO-PL-033 complete; Plumbline 0.8 publicly released from a one-commit clean-history projection; WO-PL-034 through WO-PL-038 complete; **Writwall** selected and implemented as the current identity with the two-line wall glyph; WO-PL-039 complete and -accepted; WO-PL-040 queued and inactive for the day-zero coordinator. +accepted, with public PR #5 merged after the required CI passed and issue #4 +closed; WO-PL-040 complete and accepted; WO-PL-041 authorized but not active. The hash of the commit containing this file is intentionally recorded only externally. @@ -74,9 +76,10 @@ externally. | WO-PL-036 | **COMPLETE**, accepted 2026-08-28; post-publication inception name-clearance evidence and replacement selection, not counted. Public collector/checker, worked incident, four canonical ledgers, and release-disposition pins are complete. Owner rejected Plumbline, Grantcord, and Writcord and accepted **Writwall** after named-human web/common-law and USPTO review. Windows passed 638 tests, native Ubuntu passed 74 focused/integration tests, two 112-file projections reproduced byte-for-byte, and the fifth fresh Reviewer returned ACCEPT. Active minutes **NOT REPORTED**; records retained in `governance/history/`; identity migration remains unstarted | | WO-PL-037 | **COMPLETE**, accepted 2026-08-29; controlled identity migration from Plumbline to **Writwall**, not counted. Current product surfaces, adoption skill, code constants, repository coordinates, and visual assets are migrated; historical Plumbline facts remain pinned by `identity/legacy-references.json`. Windows and Ubuntu governed-source/candidate suites passed; two private-pattern candidates were byte-identical; live-wall canary record 310 is valid; fresh Sonnet review returned **ACCEPT WITH NON-BLOCKING POLISH**. Owner active minutes **NOT REPORTED**. The closeout records one unauthorized read-only history traversal with no implementation impact. Records retained in `governance/history/`; no push, public-repository rename, release, tag, `dist/` replacement, website edit, or external launch action occurred | | WO-PL-038 | **COMPLETE**, accepted 2026-08-29; post-migration wall-glyph identity correction, not counted. The Owner rejected the inherited plumb-line/bob device and accepted the two-line wall at the `writ|wall` boundary. Focused and complete tests, identity, licensing, dispatch, and whitespace checks passed; fresh review returned **ACCEPT** after one report-only transient-count correction. Active minutes **NOT REPORTED**. Public issue #2 projection/PR update merged as public PR #3; the merged bytes were correct, but GitHub continued serving the retired banner from its unchanged published image URL, now recorded as public issue #4 | -| WO-PL-039 | **COMPLETE**, accepted 2026-08-29; post-migration cache-safe public identity and repository hardening, not counted. README now uses a digest-bound wall-glyph banner path; the live GitHub social preview is byte-identical to the accepted wall source; CI actions are full-SHA pinned behind one stable required check; supported repository rules, merge hygiene, Actions restrictions, vulnerability reporting, scanning, push protection, and topics are active. Windows passed 657 tests with two skips; the native Ubuntu candidate passed 657 with three skips; two 117-file candidates were checker-clean and byte-identical; corrected fresh re-review returned **ACCEPT**. Owner active minutes **NOT REPORTED**. Records retained in `governance/history/`; the post-closeout public issue #4 branch/PR is authorized, with merge only after live `CI required` passes | +| WO-PL-039 | **COMPLETE**, accepted 2026-08-29; post-migration cache-safe public identity and repository hardening, not counted. README now uses a digest-bound wall-glyph banner path; the live GitHub social preview is byte-identical to the accepted wall source; CI actions are full-SHA pinned behind one stable required check; supported repository rules, merge hygiene, Actions restrictions, vulnerability reporting, scanning, push protection, and topics are active. Windows passed 657 tests with two skips; the native Ubuntu candidate passed 657 with three skips; two 117-file candidates were checker-clean and byte-identical; corrected fresh re-review returned **ACCEPT**. Owner active minutes **NOT REPORTED**. Records retained in `governance/history/`; post-closeout public PR #5 merged only after both live `CI required` checks passed and public issue #4 closed | +| WO-PL-040 | **COMPLETE**, accepted 2026-08-29; executable day-zero coordinator, lifecycle-state routing, atomic create-only bootstrap handoff, external-Operator packet model, and sanitized eight-domain DNS/mail walkthrough. Three fresh reviews drove fail-closed multi-ACTIVE/link handling, complete scenario/intake coverage, and atomic publication. Final suite 687 OK with two skips; two 120-file candidates checker-clean and byte-identical; final review ACCEPT WITH NON-BLOCKING POLISH. Owner active minutes **NOT REPORTED**; records retained in `governance/history/` | | Pilot progress | **10 of 10 counted work orders complete; fresh-agent evaluation accepted with calibrations and disposed by DR-002** | -| Post-pilot sequence | **WO-PL-017 through WO-PL-023 COMPLETE**; WO-PL-024 **VOID BEFORE IMPLEMENTATION**; WO-PL-025 through WO-PL-039 **COMPLETE**; Plumbline 0.8 is the historical public release; **Writwall migration, wall-glyph correction, and cache-safe repository hardening accepted**; WO-PL-040 queued and inactive | +| Post-pilot sequence | **WO-PL-017 through WO-PL-023 COMPLETE**; WO-PL-024 **VOID BEFORE IMPLEMENTATION**; WO-PL-025 through WO-PL-040 **COMPLETE**; Plumbline 0.8 is the historical public release; **Writwall migration, repository hardening, and executable day-zero coordination accepted**; WO-PL-041 authorized but inactive pending closeout/public issue #1 sequence | | Bootstrap history | Eleven completed work orders retained as uncounted pre-adoption evidence under `archive/pre-adoption-bootstrap/` | ### Verification accepted at WO-PL-016 closeout diff --git a/identity/legacy-references.json b/identity/legacy-references.json index 75cc5a9..05ca16f 100644 --- a/identity/legacy-references.json +++ b/identity/legacy-references.json @@ -16,7 +16,7 @@ { "path": "README.md", "context": "migration_provenance", - "sha256": "4b01bc93641761adbaf3b28819abdd347f3a8ea7e557fa3fe61382027451e21c" + "sha256": "6bb209548bee395394c53b8a04cec1ed558730b1fb6af6c4f60334ab1e4a9a6d" }, { "path": "SELF-HOSTING.md", @@ -31,7 +31,7 @@ { "path": "checks/check_distribution.py", "context": "historical_evidence_path", - "sha256": "1e988ba28767063ff0d93969730321b4d7942f992edb3bbc177fbd2124c29f1b" + "sha256": "da0b19de32aa43c9b97b05d74681a306d1b3e41c0e3f4de08aa2be85c4827241" }, { "path": "checks/check_identity.py", @@ -94,7 +94,7 @@ { "path": "governance/LOG.md", "context": "historical_pilot_summary", - "sha256": "37f2db9b6959ca756c57b2f6e66fc7accb33c0323fa2d4be5d5cd94de3f0f80f", + "sha256": "d1553b059c3b35b5c540f90ee1aecbcb6cff635926a5d90bcbbfb75866dd9b6c", "projection_transform": "private_evidence_redaction" }, { @@ -105,8 +105,8 @@ { "path": "governance/STATE.md", "context": "mixed_current_state_and_history", - "sha256": "898b1411c7dc6be91d299973a1551792d35adb60cb104244d8686cac8371fb93", - "projection_sha256": "8f07eebcfd887ff6ad0900a7111e719ec51ac972565cc88f1bf6f9c1c6c73a30" + "sha256": "be7eb14613b1ddc38cdb9f294dbe4a8ef0496da84998b1c6ad3b6fc4f29d778c", + "projection_sha256": "0d878b1813dcb3e7588193b0f2d9e4fafa645db4b726261b5796f8caf74db609" }, { "path": "governance/decisions/DR-001.md", @@ -127,7 +127,7 @@ { "path": "tests/test_distribution.py", "context": "historical_evidence_fixture", - "sha256": "05698dd8ad9b22d5b40f8ab3bd733afcfc0302ea31a8769eafc80305c321b1b5" + "sha256": "9e09d9c35634505bd8ad9291812cce2aaa93a1948f8ff52e9073dc6c38462318" }, { "path": "tests/test_identity_migration.py", diff --git a/projection/public-files.txt b/projection/public-files.txt index d3b5c32..5d19dd5 100644 --- a/projection/public-files.txt +++ b/projection/public-files.txt @@ -44,6 +44,7 @@ docs/assets/writwall-og.png docs/assets/writwall-og.svg docs/assets/writwall-readme-banner-0a5259d8.png docs/assets/writwall-readme-banner.svg +docs/day-zero-coordinator.md docs/identity-migration.md docs/name-clearance.md examples/README.md @@ -85,6 +86,7 @@ projection/public-files.txt scripts/build_distribution.py scripts/build_public_projection.py scripts/collect_name_clearance.py +scripts/start_writwall.py skills/writwall-adopt/LICENSE-MAP.md skills/writwall-adopt/SKILL.md skills/writwall-adopt/assets/adapters/claude-code/README.md @@ -112,4 +114,5 @@ tests/test_identity_migration.py tests/test_init_sh.py tests/test_name_clearance.py tests/test_public_projection.py +tests/test_start_writwall.py tests/test_wo_capability_wall.py diff --git a/scripts/start_writwall.py b/scripts/start_writwall.py new file mode 100644 index 0000000..b99d0fc --- /dev/null +++ b/scripts/start_writwall.py @@ -0,0 +1,765 @@ +# SPDX-FileCopyrightText: 2026 HLLMR Ventures LLC +# SPDX-License-Identifier: Apache-2.0 +"""Create a local-first Writwall bootstrap handoff without adopting a project.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import shutil +import stat +import sys +import uuid +from dataclasses import dataclass +from pathlib import Path + + +SOURCE_ROOT = Path(__file__).resolve().parents[1] +BUNDLE_SOURCE = SOURCE_ROOT / "skills" / "writwall-adopt" +OUTPUT_NAME = ".writwall-bootstrap" +SECRET_WARNING = ( + "Do not enter passwords, API tokens, private keys, mail contents, DNS " + "record values, or other secrets. This tool writes your answers as plain " + "text inside the target project." +) + +DNS_MAIL_SCENARIO = ( + "DNS provider selection", + "DNS inventory and cutover", + "mail routing cutover", + "mailbox data migration", + "repository and website work", +) +DNS_MAIL_SCENARIO_CONTEXT = ( + "Move authoritative DNS for eight domains first; only after verified DNS " + "authority cutover, change mail routing; separately inventory, clean, and " + "migrate historical mailbox data." +) +WINDOWS_RESERVED_STEMS = frozenset({ + "con", "prn", "aux", "nul", "clock$", + *(f"com{number}" for number in range(1, 10)), + *(f"lpt{number}" for number in range(1, 10)), +}) + + +class CoordinatorError(RuntimeError): + """A safe, user-facing stop before the published output exists.""" + + +@dataclass(frozen=True) +class ObservedState: + name: str + evidence: tuple[str, ...] + active_work_order: str | None = None + + +def _is_linklike(path: Path) -> bool: + """Return true for symlinks and Windows junction/reparse entries.""" + try: + if path.is_symlink(): + return True + isjunction = getattr(os.path, "isjunction", None) + if isjunction and isjunction(path): + return True + try: + attributes = path.lstat().st_file_attributes + except FileNotFoundError: + return False + except AttributeError: + return False + return bool(attributes & stat.FILE_ATTRIBUTE_REPARSE_POINT) + except OSError: + return True + + +def _entry_exists(path: Path) -> bool: + """Unlike Path.exists(), include dangling symlinks and reparse entries.""" + try: + return os.path.lexists(path) + except OSError: + return True + + +def _safe_project_path(project: Path, path: Path, label: str) -> Path: + """Reject linklike components and containment escapes before any read.""" + try: + relative = path.relative_to(project) + except ValueError as exc: + raise CoordinatorError(f"inconsistent state: {label} is outside the project") from exc + current = project + for part in relative.parts: + current = current / part + if _is_linklike(current): + raise CoordinatorError( + f"inconsistent state: {label} contains a symlink, junction, or reparse entry" + ) + try: + resolved = path.resolve(strict=True) + except OSError as exc: + raise CoordinatorError(f"inconsistent state: {label} is unreadable: {exc}") from exc + if resolved != project and project not in resolved.parents: + raise CoordinatorError(f"inconsistent state: {label} resolves outside the project") + return resolved + + +def frontmatter_status(path: Path) -> str | None: + """Read only an exact top-level status scalar from opening frontmatter.""" + try: + lines = path.read_text(encoding="utf-8-sig").splitlines() + except (OSError, UnicodeError) as exc: + raise CoordinatorError(f"cannot read pointed work order: {exc}") from exc + if not lines or lines[0] != "---": + return None + for line in lines[1:]: + if line == "---": + break + if line.startswith((" ", "\t")): + continue + match = re.fullmatch(r"status:\s*(['\"]?)([^'\"#]+)\1\s*(?:#.*)?", line) + if match: + return match.group(2).strip() + return None + + +def _safe_pointer_target(project: Path, value: str) -> Path: + raw = value.strip().replace("\\", "/") + if not raw or "\n" in raw or "\r" in raw: + raise CoordinatorError("inconsistent state: activation pointer is empty or multiline") + parts = tuple(part for part in raw.split("/") if part) + if parts[:2] != ("governance", "work-orders") or any( + part in (".", "..") for part in parts + ): + raise CoordinatorError( + "inconsistent state: activation pointer is not a repository-relative " + "governance/work-orders path" + ) + target = project.joinpath(*parts) + resolved_target = _safe_project_path( + project, target, "activation pointer target" + ) + work_orders = _safe_project_path( + project, project / "governance" / "work-orders", "work-order directory" + ) + if work_orders not in resolved_target.parents or not resolved_target.is_file(): + raise CoordinatorError( + "inconsistent state: activation pointer target is outside the work-order " + "directory or not a regular file" + ) + return resolved_target + + +def classify_project(project: Path) -> ObservedState: + """Classify lifecycle state from repository bytes, never chat context.""" + claude_dir = project / ".claude" + if _entry_exists(claude_dir): + resolved_claude = _safe_project_path(project, claude_dir, ".claude directory") + if not resolved_claude.is_dir(): + raise CoordinatorError("inconsistent state: .claude is not a directory") + pointer = claude_dir / "active-wo.txt" + pointer_siblings = tuple( + path for path in claude_dir.glob("active-wo.*") + if path.name != "active-wo.txt" + ) if claude_dir.is_dir() else () + if pointer_siblings: + for sibling in pointer_siblings: + _safe_project_path(project, sibling, "possible activation pointer") + names = ", ".join(sorted(path.name for path in pointer_siblings)) + raise CoordinatorError( + f"inconsistent state: possible misnamed activation pointer(s): {names}" + ) + + pointed_target: Path | None = None + if _entry_exists(pointer): + resolved_pointer = _safe_project_path(project, pointer, "activation pointer") + if not resolved_pointer.is_file(): + raise CoordinatorError("inconsistent state: activation pointer is not a regular file") + try: + pointer_value = pointer.read_text(encoding="utf-8-sig") + except (OSError, UnicodeError) as exc: + raise CoordinatorError( + f"inconsistent state: activation pointer is unreadable: {exc}" + ) from exc + pointed_target = _safe_pointer_target(project, pointer_value) + status = frontmatter_status(pointed_target) + if status != "ACTIVE": + raise CoordinatorError( + "inconsistent state: activation pointer resolves, but the work order " + f"status is {status!r}, not 'ACTIVE'" + ) + live_orders = project / "governance" / "work-orders" + active_orders: list[Path] = [] + if _entry_exists(live_orders): + resolved_orders = _safe_project_path( + project, live_orders, "work-order directory" + ) + if not resolved_orders.is_dir(): + raise CoordinatorError("inconsistent state: work-order path is not a directory") + for path in live_orders.glob("*.md"): + safe_path = _safe_project_path(project, path, "work-order record") + if not safe_path.is_file(): + raise CoordinatorError("inconsistent state: work-order record is not a file") + if frontmatter_status(safe_path) == "ACTIVE": + active_orders.append(safe_path) + + if pointed_target is not None: + extras = [path for path in active_orders if path != pointed_target] + if extras or pointed_target not in active_orders: + names = ", ".join(sorted(path.name for path in active_orders)) or "none" + raise CoordinatorError( + "inconsistent state: activation pointer does not identify the only ACTIVE " + f"work order; observed ACTIVE records: {names}" + ) + relative = pointed_target.relative_to(project).as_posix() + return ObservedState( + "active_work_order", + ("activation pointer exists", f"pointed work order is ACTIVE: {relative}"), + relative, + ) + + if active_orders: + names = ", ".join(sorted(path.name for path in active_orders)) + raise CoordinatorError( + "inconsistent state: ACTIVE work order(s) exist without an activation " + f"pointer: {names}" + ) + + governance = project / "governance" + if _entry_exists(governance): + resolved_governance = _safe_project_path( + project, governance, "governance directory" + ) + if not resolved_governance.is_dir(): + raise CoordinatorError("inconsistent state: governance is not a directory") + core = tuple(governance / name for name in ("PLAN.md", "STATE.md", "ROUTING.md")) + adoption_records = ( + governance / "decisions" / "DR-001.md", + governance / "ADOPTION-RECORD.md", + ) + history = governance / "history" + closed_records = [] + if _entry_exists(history): + resolved_history = _safe_project_path(project, history, "history directory") + if not resolved_history.is_dir(): + raise CoordinatorError("inconsistent state: history is not a directory") + for path in history.glob("WO-*.md"): + safe_path = _safe_project_path(project, path, "historical work-order record") + if not safe_path.is_file(): + raise CoordinatorError( + "inconsistent state: historical work-order record is not a file" + ) + if frontmatter_status(safe_path) in {"CLOSED", "COMPLETE"}: + closed_records.append(safe_path) + + for path in (*core, *adoption_records): + if _entry_exists(path): + resolved = _safe_project_path(project, path, "governance control file") + if not resolved.is_file(): + raise CoordinatorError( + "inconsistent state: governance control path is not a file" + ) + + if all(path.is_file() for path in core) and closed_records: + return ObservedState( + "retired_lockout", + ( + "activation pointer is absent", + "Plan, State, and Routing exist", + f"{len(closed_records)} closed work-order record(s) observed in history", + ), + ) + if all(path.is_file() for path in core) and any( + path.is_file() for path in adoption_records + ): + return ObservedState( + "adopted_lockout", + ( + "activation pointer is absent", + "Plan, State, Routing, and an adoption record exist", + ), + ) + + writwall_markers = ( + project / ".claude" / "hooks" / "wo_capability_wall.py", + project / ".claude" / "settings.json", + governance / "PLAN.md", + governance / "STATE.md", + governance / "ROUTING.md", + project / "START-HERE.md", + project / "ADOPTING.md", + ) + found_items = [] + for path in writwall_markers: + if _entry_exists(path): + _safe_project_path(project, path, "Writwall marker") + found_items.append(path.relative_to(project).as_posix()) + found = tuple(found_items) + if found: + return ObservedState( + "partial_bootstrap", + ("activation pointer is absent", "Writwall-shaped material exists: " + ", ".join(found)), + ) + return ObservedState( + "clean_new", + ("activation pointer is absent", "no Writwall control-plane or governance markers found"), + ) + + +def portable_slug(value: str) -> str: + slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") + if not slug: + raise CoordinatorError(f"external Operator function {value!r} has no portable name") + slug = slug[:80].rstrip("-") + if slug in WINDOWS_RESERVED_STEMS: + slug = f"operator-{slug}" + return slug + + +def operation_packet(function_name: str) -> str: + return f"""# External operations packet: {function_name} + +This scaffold is inert. It confers no authority to access or mutate any system. +The Owner-Agent fills it from ratified intent; the named Operator executes only +the completed packet and returns evidence. + +## Preconditions + +- [ ] Identify the exact system, account boundary, and observed baseline. +- [ ] Name a stop condition for unexpected state. + +## Permitted actions + +- [ ] List exact authorized actions; an empty list authorizes nothing. + +## Prohibited actions + +- No repository-byte edits unless a separate repository work order grants them. +- No credential disclosure, persistence, or transmission through this packet. +- No adjacent-system change merely because it is convenient. + +## Verification + +- [ ] State exact observations and pass conditions. + +## Rollback + +- [ ] State the last safe point and exact restoration procedure. + +## Evidence to return + +- [ ] Return timestamps, sanitized before/after observations, and command or UI results. +- Never return credentials, private keys, mailbox content, or secret record values. + +## Credential boundary + +Credentials remain in the Operator's approved secret store or interactive +provider session. The Owner may authenticate when unavoidable; authentication +does not transfer decision authority. This external Operator remains outside +the repository capability wall unless it edits repository bytes. +""" + + +def role_split_recommendation(functions: tuple[str, ...]) -> str: + external = ( + f" Add {len(functions)} separately bounded external function packet(s); " + "the Owner may assign multiple packets to one Operator only when the " + "same account boundary, verification, and rollback apply." + if functions else + " Add no external Operator unless the project later names an external system." + ) + return ( + "Use one human Owner, one Owner-Agent coordinator, one repository " + "Operator when repository mutation begins, and one fresh Reviewer." + + external + ) + + +def next_prompt(state: ObservedState) -> tuple[str, str]: + if state.name == "clean_new": + return ( + "Adoption coordinator before wall registration", + """Act as my Writwall adoption coordinator, not as an Implementer. Read +`.writwall-bootstrap/writwall-adopt/SKILL.md` and use bootstrap mode. Treat +`.writwall-bootstrap/intake.json` as unratified intake, not authority. I decide +and ratify; perform every clerical step an authorized recorder may perform. +Ask one question at a time in plain language, recommendation first. Do not +install or register the wall until the complete bundle and recovery instructions +are locally readable. Do not begin product work or WO-001 before adoption.""", + ) + if state.name == "partial_bootstrap": + return ( + "External recovery coordinator", + """Act as my Writwall accidental-overlay or incomplete-adoption recovery coordinator, +not as an Implementer. Read `.writwall-bootstrap/writwall-adopt/SKILL.md`. +Inventory only; do not delete, overwrite, move, install, register, activate, or +invent missing intent. Use the observed-state evidence in the handoff, propose +an exact disposition packet, and ask one question at a time.""", + ) + if state.name in {"adopted_lockout", "retired_lockout"}: + return ( + "Dispatcher for a new candidate work order", + """Act as Dispatcher. The repository is in observed lockout; no active work order +is established. Read the charter, Plan, State, Routing, and ratified adoption +record. Draft one bounded work order for the project's genuine next task. +Generate and validate its boundaries, but do not activate it. Return the exact +candidate and scope rationale for Owner approval.""", + ) + if state.name == "active_work_order": + return ( + "Walled repository Implementer", + """Act as Implementer for the active work order only. Re-read the activation +pointer and pointed work order from repository bytes, confirm the active +dispatch and required live-wall canary before mutation, execute only its grant, +write its report, and stop before acceptance or closeout.""", + ) + raise CoordinatorError(f"inconsistent state: unsupported classification {state.name!r}") + + +def render_time(owner_time: str) -> str: + if owner_time == "yes": + return """**Owner active-minute capture: ENABLED.** Start when the first intake +question is presented. Stop when the coordinator returns the ratifiable adoption +packet or next-work-order candidate. Human reading, deciding, responding, +authentication, and unavoidable UI work count. Agent execution and waiting do +not. Report the actual total at acceptance; never reconstruct it later.""" + return """**Owner active minutes: NOT REPORTED.** Capture was declined for this +bootstrap. Do not infer or reconstruct a value.""" + + +def render_handoff(args: argparse.Namespace, state: ObservedState, + functions: tuple[str, ...]) -> str: + role, prompt = next_prompt(state) + evidence = "\n".join(f"- {item}" for item in state.evidence) + operator_rows = "\n".join( + f"- **{name}:** give `operations/{portable_slug(name)}.md` to the agent " + "or administrator that can reach only that external function." + for name in functions + ) or "- No external Operator function was named during intake." + active = ( + f"\nPointed work order: `{state.active_work_order}`." + if state.active_work_order else "" + ) + scenario = ( + "\n## Scenario boundary\n\n" + f"{DNS_MAIL_SCENARIO_CONTEXT}\n" + if args.scenario == "dns-mail-migration" else "" + ) + return f"""# Writwall day-zero handoff: {args.project_name} + +This directory is temporary bootstrap material. It does not install or adopt +Writwall, ratify intent, activate a work order, or grant external authority. +Remove it before the adoption commit after the authorized recorder no longer +needs it. + +{SECRET_WARNING} + +## Observed lifecycle state + +**{state.name}**{active} + +{evidence} + +Repository bytes, not prior chat, determine this state. Re-run the coordinator +if those bytes change before acting. + +## Project intake + +- Project: {args.project_name} +- Purpose: {args.purpose} +- Preferred primary agent/interface: {args.agent} +- Execution location: {args.location} +- Repository and external environment: {args.environment} + +These answers are unratified intake. The Owner must approve material intent. +{scenario} +## Recommended smallest credible role split + +{role_split_recommendation(functions)} + +## Who to open next + +**{role}**, using **{args.agent}** at **{args.location}**. + +Paste exactly: + +```text +{prompt} +``` + +## Authority and mechanics + +- The human Owner decides intent, identity, risk acceptance, lifecycle actions, + provider selection, production cutovers, and acceptance. +- The Owner-Agent may interview, draft, route, record exact ratified decisions, + and perform explicitly authorized clerical lifecycle mechanics. +- A repository Operator works only under the active work order. +- A fresh Reviewer evaluates the order, result, evidence, and report without + implementing corrections in the same context. +- External Operators receive only bounded packets. The Owner-Agent retains the + proverbial keys: routing and authority, not passwords or cryptographic keys. + +## External Operator routing + +{operator_rows} + +Provider selection, DNS authority, mail routing, mailbox data, repository work, +and deployment are separate decision or execution boundaries. Do not collapse +them into one broad infrastructure authorization. + +## Owner-time measurement + +{render_time(args.owner_time)} +""" + + +def write_bootstrap(project: Path, args: argparse.Namespace, state: ObservedState, + functions: tuple[str, ...]) -> Path: + output = project / OUTPUT_NAME + if _entry_exists(output): + raise CoordinatorError( + f"create-only stop: {OUTPUT_NAME} already exists; nothing was overwritten" + ) + if _is_linklike(BUNDLE_SOURCE) or not BUNDLE_SOURCE.is_dir(): + raise CoordinatorError("Writwall adoption bundle is missing from this distribution") + bundle_files = sorted(path for path in BUNDLE_SOURCE.rglob("*") if path.is_file()) + if not bundle_files: + raise CoordinatorError("Writwall adoption bundle is empty") + for source in bundle_files: + current = BUNDLE_SOURCE + for part in source.relative_to(BUNDLE_SOURCE).parts: + current = current / part + if _is_linklike(current): + raise CoordinatorError( + "Writwall adoption bundle contains a symlink, junction, or reparse entry" + ) + + slugs = [portable_slug(name) for name in functions] + if len(slugs) != len(set(slugs)): + raise CoordinatorError("external Operator names collapse to duplicate portable filenames") + + stage = project.parent / ( + f".{project.name}-writwall-bootstrap-stage-{uuid.uuid4().hex}" + ) + try: + stage.mkdir() + except OSError as exc: + raise CoordinatorError( + f"cannot create same-filesystem bootstrap stage outside the target: {exc}" + ) from exc + try: + bundle_target = stage / "writwall-adopt" + for source in bundle_files: + relative = source.relative_to(BUNDLE_SOURCE) + target = bundle_target / relative + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(source.read_bytes()) + + intake = { + "schema": 1, + "observed_state": state.name, + "state_evidence": list(state.evidence), + "active_work_order": state.active_work_order, + "project_name": args.project_name, + "purpose": args.purpose, + "preferred_agent": args.agent, + "execution_location": args.location, + "repository_external_environment": args.environment, + "scenario": args.scenario, + "scenario_context": ( + DNS_MAIL_SCENARIO_CONTEXT + if args.scenario == "dns-mail-migration" else None + ), + "recommended_role_split": role_split_recommendation(functions), + "external_operator_functions": list(functions), + "owner_time_capture": args.owner_time == "yes", + "project_root": ".", + "authority": "unratified_intake_only", + } + (stage / "intake.json").write_text( + json.dumps(intake, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + newline="\n", + ) + (stage / "HANDOFF.md").write_text( + render_handoff(args, state, functions), + encoding="utf-8", + newline="\n", + ) + if functions: + operations = stage / "operations" + operations.mkdir() + for name, slug in zip(functions, slugs): + (operations / f"{slug}.md").write_text( + operation_packet(name), encoding="utf-8", newline="\n" + ) + if _entry_exists(output): + raise CoordinatorError( + f"create-only stop: {OUTPUT_NAME} appeared during creation; nothing was overwritten" + ) + os.rename(stage, output) + except Exception as exc: + cleanup_error = None + if _entry_exists(stage): + try: + shutil.rmtree(stage) + except OSError as cleanup_exc: + cleanup_error = cleanup_exc + if _entry_exists(stage): + raise CoordinatorError( + "bootstrap publication failed and the complete external stage " + f"could not be removed: {stage} ({cleanup_error})" + ) from exc + raise CoordinatorError( + f"bootstrap creation stopped before atomic publication; no target " + f"output was created: {exc}" + ) from exc + return output + + +def ask(prompt: str, default: str | None = None) -> str: + suffix = f" [{default}]" if default else "" + answer = input(f"{prompt}{suffix}: ").strip() + return answer or (default or "") + + +def interactive_args(args: argparse.Namespace) -> argparse.Namespace: + print(SECRET_WARNING) + if not args.owner_time: + args.owner_time = ask( + "Track Owner active minutes? If yes, start a timer before answering", + "yes", + ).lower() + if args.owner_time == "yes": + print( + "Owner timer starts now: count your reading, decisions, responses, " + "authentication, and unavoidable UI work; exclude agent waiting." + ) + confirmation = ask("Continue without entering secrets?", "yes").lower() + if confirmation not in {"y", "yes"}: + raise CoordinatorError("stopped before reading project intake") + args.confirm_no_secrets = True + args.project_root = args.project_root or ask("Target project directory", ".") + args.project_name = args.project_name or ask("Project name") + if not args.brief_file and not args.purpose: + supplied_brief = ask("Existing project brief file path, or blank", "") + if supplied_brief: + args.brief_file = supplied_brief + if args.brief_file: + args.purpose = "" + else: + args.purpose = args.purpose or ask("Explain the project purpose in your own words") + args.agent = args.agent or ask( + "Preferred primary agent and interface", "Claude Code in VS Code" + ) + args.location = args.location or ask( + "Where will that agent run?", "local project workspace" + ) + args.environment = args.environment or ask( + "Describe the repository and any external environment it must coordinate with", + "local repository only", + ) + print( + "Recommended minimum: one Owner-Agent, one repository Operator when " + "mutation starts, one fresh Reviewer, and external Operators only for " + "distinct account or rollback boundaries." + ) + if not args.external_operator and not args.scenario: + external = ask( + "External Operator functions, comma-separated (DNS, mail, VPS), or blank", + "", + ) + args.external_operator = [part.strip() for part in external.split(",") if part.strip()] + return args + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Prepare a create-only, project-local Writwall adoption handoff. " + "This does not install or adopt Writwall." + ) + ) + parser.add_argument("--project-root") + parser.add_argument("--project-name") + parser.add_argument("--purpose") + parser.add_argument("--brief-file") + parser.add_argument("--agent") + parser.add_argument("--location") + parser.add_argument("--environment") + parser.add_argument("--external-operator", action="append", default=[]) + parser.add_argument("--scenario", choices=("dns-mail-migration",)) + parser.add_argument("--owner-time", choices=("yes", "no")) + parser.add_argument("--confirm-no-secrets", action="store_true") + parser.add_argument("--non-interactive", action="store_true") + return parser.parse_args(argv) + + +def normalize_args(args: argparse.Namespace) -> tuple[argparse.Namespace, Path, tuple[str, ...]]: + if not args.non_interactive: + args = interactive_args(args) + required = { + "project root": args.project_root, + "project name": args.project_name, + "primary agent/interface": args.agent, + "execution location": args.location, + "repository/external environment": args.environment, + "Owner-time choice": args.owner_time, + } + missing = [name for name, value in required.items() if not value] + if args.non_interactive and not args.confirm_no_secrets: + raise CoordinatorError( + "non-interactive use requires --confirm-no-secrets; secrets must not be supplied" + ) + if missing: + raise CoordinatorError("missing required intake: " + ", ".join(missing)) + + supplied_project = Path(args.project_root).expanduser() + if supplied_project.is_symlink(): + raise CoordinatorError("target project must not be a symlink") + try: + project = supplied_project.resolve(strict=True) + except OSError as exc: + raise CoordinatorError(f"target project directory is not readable: {exc}") from exc + if not project.is_dir() or project.is_symlink(): + raise CoordinatorError("target project must be an existing, non-symlink directory") + + if args.brief_file: + brief = Path(args.brief_file).expanduser() + try: + purpose = brief.read_text(encoding="utf-8-sig").strip() + except (OSError, UnicodeError) as exc: + raise CoordinatorError(f"cannot read supplied brief: {exc}") from exc + if not purpose: + raise CoordinatorError("supplied brief is empty") + args.purpose = purpose + if not args.purpose: + raise CoordinatorError("missing project purpose or --brief-file") + + functions = list(args.external_operator) + if args.scenario == "dns-mail-migration": + functions.extend(name for name in DNS_MAIL_SCENARIO if name not in functions) + normalized = tuple(name.strip() for name in functions if name.strip()) + return args, project, normalized + + +def main(argv: list[str] | None = None) -> int: + try: + args, project, functions = normalize_args(parse_args(argv)) + if _entry_exists(project / OUTPUT_NAME): + raise CoordinatorError( + f"create-only stop: {OUTPUT_NAME} already exists; nothing was overwritten" + ) + state = classify_project(project) + output = write_bootstrap(project, args, state, functions) + except CoordinatorError as exc: + print(f"STOP: {exc}", file=sys.stderr) + return 2 + print(f"Observed lifecycle state: {state.name}") + print(f"Created: {output.as_posix()}") + print(f"Next: read {(output / 'HANDOFF.md').as_posix()}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/writwall-adopt/SKILL.md b/skills/writwall-adopt/SKILL.md index ead35f6..08bfa01 100644 --- a/skills/writwall-adopt/SKILL.md +++ b/skills/writwall-adopt/SKILL.md @@ -7,6 +7,20 @@ description: Bootstrap a repository for adoption of the Writwall Doctrine (docum You are the bootstrap implementer for a project adopting the Doctrine. You are not the Owner. You prepare; the Owner adopts. +## Day-zero handoff + +When `.writwall-bootstrap/HANDOFF.md` and `intake.json` are supplied, read both +before choosing a mode. The intake is unratified and carries no authority. Re- +observe the target repository's activation pointer and pointed work-order +status before acting; repository bytes override the handoff if state changed. +Never infer an active work order from prior chat or a closed history record. + +The day-zero command already made this complete bundle local. Do not fetch a +replacement or register the wall before its recovery instructions are readable. +External-operation packet scaffolds are inert: blank fields authorize nothing, +credentials remain outside them, and infrastructure, DNS, and mail Operators +remain outside the repository wall unless they edit repository bytes. + Read `references/DOCTRINE.md` in this skill bundle before doing anything, and cite clauses in your report. Doctrine 1.2.3 permits a bootstrap agent to receive the doctrine as an implementation specification under direct Owner supervision. That permission is bounded by this task: it ends when bootstrap ends. ## Two modes diff --git a/tests/test_check_distribution.py b/tests/test_check_distribution.py index 1ca368f..eec62f1 100644 --- a/tests/test_check_distribution.py +++ b/tests/test_check_distribution.py @@ -62,6 +62,12 @@ "tests/test_public_projection.py", ) +DAY_ZERO_REQUIRED_FILES = ( + "docs/day-zero-coordinator.md", + "scripts/start_writwall.py", + "tests/test_start_writwall.py", +) + class DispatchCheckerPackagingTests(unittest.TestCase): def setUp(self): @@ -282,6 +288,31 @@ def test_dispatch_checker_and_test_are_required(self): self.assertIn(name, module.REQUIRED_FILES, f"{name} is not in check_distribution.py REQUIRED_FILES") + def test_day_zero_coordinator_files_are_required(self): + import importlib.util + spec = importlib.util.spec_from_file_location( + "_check_distribution_day_zero", + REPO_ROOT / "checks" / "check_distribution.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + for name in DAY_ZERO_REQUIRED_FILES: + self.assertIn(name, module.REQUIRED_FILES, + f"{name} is not required by the distribution gate") + + def test_missing_day_zero_coordinator_file_fails_distribution_gate(self): + for relpath in DAY_ZERO_REQUIRED_FILES: + with self.subTest(relpath=relpath): + target = self.repo / relpath + original = target.read_bytes() + target.unlink() + try: + result = self.check() + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertIn(f"[required-file] missing: {relpath}", result.stdout) + finally: + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(original) + def test_missing_dispatch_checker_fails_distribution_gate(self): (self.repo / "checks" / "check_work_order_dispatch.py").unlink() result = self.check() diff --git a/tests/test_distribution.py b/tests/test_distribution.py index bf6bc2f..505d3fb 100644 --- a/tests/test_distribution.py +++ b/tests/test_distribution.py @@ -3144,5 +3144,44 @@ def test_pilot_example_claiming_full_enforcement_fails_doc_truth(self): self.assertNotIn("provider envelope", combined) +class DayZeroCoordinatorContractTests(DistributionTestCase): + """WO-PL-040: the executable front door and all human routes stay aligned.""" + + def test_clean_tree_carries_the_day_zero_contract(self): + result = self.check() + self.assertNotIn("[onboarding]", result.stdout) + for relpath in ( + "docs/day-zero-coordinator.md", + "scripts/start_writwall.py", + "tests/test_start_writwall.py", + ): + self.assertTrue((self.repo / relpath).is_file(), relpath) + + def test_readme_losing_single_entry_command_fails(self): + self.edit("README.md", "scripts/start_writwall.py", "scripts/manual-start.py", + count=-1) + self.assert_fails(self.check(), "onboarding") + + def test_start_here_losing_temporary_bundle_fails(self): + self.edit("START-HERE.md", ".writwall-bootstrap/", ".temporary-bootstrap/") + self.assert_fails(self.check(), "onboarding") + + def test_skill_losing_repository_state_rule_fails(self): + self.edit( + "skills/writwall-adopt/SKILL.md", + "Never infer an active work order from prior chat", + "Use the work order remembered from prior chat", + ) + self.assert_fails(self.check(), "onboarding") + + def test_coordinator_reference_losing_authority_boundary_fails(self): + self.edit( + "docs/day-zero-coordinator.md", + "confers no authority", + "is an authorization", + ) + self.assert_fails(self.check(), "onboarding") + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_start_writwall.py b/tests/test_start_writwall.py new file mode 100644 index 0000000..4a3634c --- /dev/null +++ b/tests/test_start_writwall.py @@ -0,0 +1,422 @@ +# SPDX-FileCopyrightText: 2026 HLLMR Ventures LLC +# SPDX-License-Identifier: Apache-2.0 +"""Public-interface tests for the day-zero Writwall coordinator.""" + +from __future__ import annotations + +import json +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest import mock + +from scripts import start_writwall as starter_module + + +REPO_ROOT = Path(__file__).resolve().parents[1] +STARTER = REPO_ROOT / "scripts" / "start_writwall.py" + + +class StartWritwallTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = Path(tempfile.mkdtemp()).resolve() + self.addCleanup(shutil.rmtree, self.temp, True) + self.project = self.temp / "project" + self.project.mkdir() + + def run_start(self, *extra: str, project: Path | None = None): + return subprocess.run( + [ + sys.executable, + "-B", + str(STARTER), + "--non-interactive", + "--project-root", + str(project or self.project), + "--project-name", + "Example project", + "--purpose", + "Build a small, governed project.", + "--agent", + "Claude Code in VS Code", + "--location", + "local workstation", + "--environment", + "local repository with separately administered hosting", + "--owner-time", + "no", + "--confirm-no-secrets", + *extra, + ], + cwd=REPO_ROOT, + capture_output=True, + text=True, + timeout=60, + ) + + @property + def output(self) -> Path: + return self.project / ".writwall-bootstrap" + + def intake(self) -> dict: + return json.loads((self.output / "intake.json").read_text(encoding="utf-8")) + + def handoff(self) -> str: + return (self.output / "HANDOFF.md").read_text(encoding="utf-8") + + def test_clean_project_creates_bundle_and_exact_handoff(self): + result = self.run_start() + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(self.intake()["observed_state"], "clean_new") + self.assertTrue((self.output / "writwall-adopt" / "SKILL.md").is_file()) + handoff = self.handoff() + flat = " ".join(handoff.split()) + self.assertIn("Act as my Writwall adoption coordinator", handoff) + self.assertIn("does not install or adopt Writwall", flat) + self.assertIn("Do not enter passwords, API tokens", handoff) + + def test_create_only_refuses_existing_output_without_overwrite(self): + self.output.mkdir() + sentinel = self.output / "keep.txt" + sentinel.write_text("unchanged", encoding="utf-8") + result = self.run_start() + self.assertNotEqual(result.returncode, 0) + self.assertEqual(sentinel.read_text(encoding="utf-8"), "unchanged") + self.assertEqual(sorted(p.name for p in self.output.iterdir()), ["keep.txt"]) + + def test_missing_secret_confirmation_fails_before_output(self): + result = subprocess.run( + [ + sys.executable, + "-B", + str(STARTER), + "--non-interactive", + "--project-root", + str(self.project), + "--project-name", + "Example", + "--purpose", + "Example", + "--agent", + "Codex", + "--location", + "desktop", + "--environment", + "local repository", + "--owner-time", + "no", + ], + cwd=REPO_ROOT, + capture_output=True, + text=True, + timeout=60, + ) + self.assertNotEqual(result.returncode, 0) + self.assertFalse(self.output.exists()) + self.assertIn("secrets", (result.stdout + result.stderr).lower()) + + def test_active_pointer_routes_to_implementer_only_when_target_is_active(self): + work_order = self.project / "governance" / "work-orders" / "WO-001.md" + work_order.parent.mkdir(parents=True) + work_order.write_text("---\nid: WO-001\nstatus: ACTIVE\n---\n# Work\n", + encoding="utf-8") + pointer = self.project / ".claude" / "active-wo.txt" + pointer.parent.mkdir(parents=True) + pointer.write_text("governance/work-orders/WO-001.md\n", encoding="utf-8") + result = self.run_start() + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(self.intake()["observed_state"], "active_work_order") + self.assertIn("Act as Implementer for the active work order only", self.handoff()) + + def test_pointer_plus_second_active_order_stops_as_inconsistent(self): + orders = self.project / "governance" / "work-orders" + orders.mkdir(parents=True) + for name in ("WO-001.md", "WO-002.md"): + (orders / name).write_text( + f"---\nid: {name[:-3]}\nstatus: ACTIVE\n---\n", + encoding="utf-8", + ) + pointer = self.project / ".claude" / "active-wo.txt" + pointer.parent.mkdir(parents=True) + pointer.write_text("governance/work-orders/WO-001.md\n", encoding="utf-8") + result = self.run_start() + self.assertNotEqual(result.returncode, 0) + self.assertFalse(self.output.exists()) + self.assertIn("only active", (result.stdout + result.stderr).lower()) + + def test_missing_pointer_with_closed_history_never_emits_resume_prompt(self): + closed = self.project / "governance" / "history" / "WO-001.md" + closed.parent.mkdir(parents=True) + closed.write_text("---\nid: WO-001\nstatus: CLOSED\n---\n", encoding="utf-8") + for name in ("PLAN.md", "STATE.md", "ROUTING.md"): + (self.project / "governance" / name).write_text(f"# {name}\n", encoding="utf-8") + result = self.run_start() + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(self.intake()["observed_state"], "retired_lockout") + handoff = self.handoff() + self.assertIn("Act as Dispatcher", handoff) + self.assertNotIn("resume", handoff.lower()) + self.assertNotIn("Act as Implementer", handoff) + + def test_pointer_to_closed_order_is_inconsistent_and_creates_nothing(self): + work_order = self.project / "governance" / "work-orders" / "WO-001.md" + work_order.parent.mkdir(parents=True) + work_order.write_text("---\nid: WO-001\nstatus: CLOSED\n---\n", encoding="utf-8") + pointer = self.project / ".claude" / "active-wo.txt" + pointer.parent.mkdir(parents=True) + pointer.write_text("governance/work-orders/WO-001.md\n", encoding="utf-8") + result = self.run_start() + self.assertNotEqual(result.returncode, 0) + self.assertFalse(self.output.exists()) + self.assertIn("inconsistent", (result.stdout + result.stderr).lower()) + + def test_partial_bootstrap_routes_to_recovery_coordinator(self): + settings = self.project / ".claude" / "settings.json" + settings.parent.mkdir(parents=True) + settings.write_text("{}\n", encoding="utf-8") + result = self.run_start() + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(self.intake()["observed_state"], "partial_bootstrap") + self.assertIn("recovery coordinator", self.handoff()) + + def test_adopted_lockout_routes_to_dispatcher(self): + governance = self.project / "governance" + governance.mkdir() + for name in ("PLAN.md", "STATE.md", "ROUTING.md"): + (governance / name).write_text(f"# {name}\n", encoding="utf-8") + decision = governance / "decisions" / "DR-001.md" + decision.parent.mkdir() + decision.write_text("# Adoption record\n", encoding="utf-8") + result = self.run_start() + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(self.intake()["observed_state"], "adopted_lockout") + self.assertIn("Act as Dispatcher", self.handoff()) + + def test_owner_time_yes_defines_capture_and_no_records_not_reported(self): + yes = self.run_start("--owner-time", "yes") + self.assertEqual(yes.returncode, 0, yes.stdout + yes.stderr) + handoff = self.handoff() + flat = " ".join(handoff.split()) + self.assertIn("Owner active-minute capture: ENABLED", handoff) + self.assertIn("Human reading, deciding, responding, authentication", flat) + + shutil.rmtree(self.output) + no = self.run_start() + self.assertEqual(no.returncode, 0, no.stdout + no.stderr) + self.assertIn("Owner active minutes: NOT REPORTED", self.handoff()) + + def test_external_operators_receive_separate_inert_packets(self): + result = self.run_start( + "--external-operator", "DNS authority migration", + "--external-operator", "mail routing cutover", + "--external-operator", "mailbox data migration", + "--external-operator", "repository and website work", + ) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + packets = sorted(p.name for p in (self.output / "operations").glob("*.md")) + self.assertEqual(packets, [ + "dns-authority-migration.md", + "mail-routing-cutover.md", + "mailbox-data-migration.md", + "repository-and-website-work.md", + ]) + for path in (self.output / "operations").glob("*.md"): + text = path.read_text(encoding="utf-8") + for heading in ( + "## Preconditions", "## Permitted actions", "## Prohibited actions", + "## Verification", "## Rollback", "## Evidence to return", + "## Credential boundary", + ): + self.assertIn(heading, text) + self.assertIn("confers no authority", text) + + def test_dns_mail_scenario_is_split_without_real_values(self): + result = self.run_start("--scenario", "dns-mail-migration") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + intake = self.intake() + self.assertEqual(intake["external_operator_functions"], [ + "DNS provider selection", + "DNS inventory and cutover", + "mail routing cutover", + "mailbox data migration", + "repository and website work", + ]) + combined = self.handoff() + "\n" + "\n".join( + path.read_text(encoding="utf-8") + for path in (self.output / "operations").glob("*.md") + ) + self.assertNotIn("fastmail", combined.lower()) + self.assertNotIn("proton", combined.lower()) + self.assertNotIn("hllmr", combined.lower()) + self.assertIn("eight domains", combined.lower()) + self.assertIn("dns authority cutover", combined.lower()) + self.assertIn("historical mailbox data", combined.lower()) + self.assertLess( + combined.lower().index("dns authority cutover"), + combined.lower().index("change mail routing"), + ) + + def test_complete_bundle_is_byte_identical_to_source(self): + result = self.run_start() + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + source = REPO_ROOT / "skills" / "writwall-adopt" + copied = self.output / "writwall-adopt" + source_files = sorted( + path.relative_to(source).as_posix() + for path in source.rglob("*") if path.is_file() + ) + copied_files = sorted( + path.relative_to(copied).as_posix() + for path in copied.rglob("*") if path.is_file() + ) + self.assertEqual(copied_files, source_files) + for relative in source_files: + self.assertEqual((copied / relative).read_bytes(), + (source / relative).read_bytes(), relative) + + def test_role_split_recommends_minimum_and_bounded_external_functions(self): + result = self.run_start( + "--external-operator", "DNS administration", + "--external-operator", "mail administration", + ) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + handoff = self.handoff() + self.assertIn("Recommended smallest credible role split", handoff) + self.assertIn("one human Owner, one Owner-Agent coordinator", handoff) + self.assertIn("2 separately bounded external function packet(s)", handoff) + + def test_windows_reserved_operator_name_is_made_portable(self): + result = self.run_start("--external-operator", "CON") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertTrue((self.output / "operations" / "operator-con.md").is_file()) + + def test_dangling_output_symlink_is_a_collision(self): + missing = self.project / "missing-output-target" + try: + self.output.symlink_to(missing, target_is_directory=True) + except OSError as exc: + self.skipTest(f"symlink creation unavailable: {exc}") + result = self.run_start() + self.assertNotEqual(result.returncode, 0) + self.assertTrue(self.output.is_symlink()) + self.assertFalse(missing.exists()) + + def test_atomic_publication_failure_leaves_no_target_output_or_stage(self): + args = SimpleNamespace( + project_name="Example", + purpose="Example purpose", + agent="Codex", + location="desktop", + environment="local repository", + owner_time="no", + scenario=None, + ) + state = starter_module.ObservedState( + "clean_new", ("activation pointer is absent",) + ) + stage_pattern = f".{self.project.name}-writwall-bootstrap-stage-*" + with mock.patch.object( + starter_module.os, "rename", side_effect=OSError("injected rename failure") + ): + with self.assertRaises(starter_module.CoordinatorError) as raised: + starter_module.write_bootstrap(self.project, args, state, ()) + self.assertIn("before atomic publication", str(raised.exception)) + self.assertFalse(self.output.exists()) + self.assertEqual(list(self.project.parent.glob(stage_pattern)), []) + + def test_nested_history_symlink_stops_before_external_read(self): + external = self.temp / "external-history" + external.mkdir() + (external / "WO-001.md").write_text( + "---\nid: WO-001\nstatus: CLOSED\n---\nSECRET-SENTINEL\n", + encoding="utf-8", + ) + governance = self.project / "governance" + governance.mkdir() + for name in ("PLAN.md", "STATE.md", "ROUTING.md"): + (governance / name).write_text(f"# {name}\n", encoding="utf-8") + try: + (governance / "history").symlink_to(external, target_is_directory=True) + except OSError as exc: + self.skipTest(f"symlink creation unavailable: {exc}") + result = self.run_start() + self.assertNotEqual(result.returncode, 0) + self.assertFalse(self.output.exists()) + self.assertIn("symlink", (result.stdout + result.stderr).lower()) + self.assertNotIn("SECRET-SENTINEL", result.stdout + result.stderr) + + def test_junction_detection_fallback_is_exercised(self): + ordinary = self.project / "ordinary" + ordinary.mkdir() + with mock.patch.object( + starter_module.os.path, "isjunction", create=True, return_value=True + ): + self.assertTrue(starter_module._is_linklike(ordinary)) + + def test_interactive_flow_offers_brief_and_time_capture_before_intake(self): + brief = self.temp / "existing-brief.md" + brief.write_text("Existing project thesis.\n", encoding="utf-8") + result = subprocess.run( + [ + sys.executable, "-B", str(STARTER), + "--project-root", str(self.project), + "--project-name", "Interactive project", + ], + cwd=REPO_ROOT, + input=( + "yes\n" # Owner-time choice + "yes\n" # no-secret confirmation + f"{brief}\n" + "\n" # default agent + "\n" # default location + "\n" # default environment + "\n" # no external functions + ), + capture_output=True, + text=True, + timeout=60, + ) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertLess( + result.stdout.index("Track Owner active minutes"), + result.stdout.index("Continue without entering secrets"), + ) + self.assertIn("Existing project brief file path", result.stdout) + self.assertEqual(self.intake()["purpose"], "Existing project thesis.") + + def test_brief_file_is_read_but_never_modified(self): + brief = self.temp / "brief.md" + brief.write_text("A supplied project thesis.\n", encoding="utf-8") + result = self.run_start("--brief-file", str(brief)) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(brief.read_text(encoding="utf-8"), + "A supplied project thesis.\n") + self.assertEqual(self.intake()["purpose"], "A supplied project thesis.") + + def test_paths_are_recorded_portably(self): + result = self.run_start() + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + text = (self.output / "intake.json").read_text(encoding="utf-8") + self.assertNotIn("\\", text) + self.assertIn(".writwall-bootstrap", self.handoff()) + + def test_environment_is_captured_without_becoming_authority(self): + result = self.run_start() + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual( + self.intake()["repository_external_environment"], + "local repository with separately administered hosting", + ) + self.assertIn( + "Repository and external environment: local repository with separately administered hosting", + self.handoff(), + ) + self.assertEqual(self.intake()["authority"], "unratified_intake_only") + + +if __name__ == "__main__": + unittest.main()