@afokapu/atdd-bun 0.8.1 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -55,7 +55,7 @@ registerEnforcementTest({ root: import.meta.dir + "/..", profiles: ["traceabilit
55
55
  | `topology` | feature decomposition and the plan, source, test and E2E locations |
56
56
  | `planner` | schemas for every plan artifact, graph integrity, the scoped planner rules |
57
57
  | `telemetry` | the telemetry tracking plan: item shape, path-mirrored identity and versioning under `telemetry/`, wagon ownership of logical artifacts, the per-acceptance telemetry decision, metric label cardinality, source `Telemetry:` references, raw-string and forbidden-property emission, the vendor-SDK boundary around the TelemetryPort, and telemetry tests that bind the acceptance and item, assert the exact identity on a captured sink, cover every required item, and exercise declared timing semantics |
58
- | `delivery` | the review record of each tranche under `delivery/`: allowed author and reviewer models with recorded fallbacks, reviewer independence, every finding fixed, withdrawn after one dispute or ruled on by a human, every configured stage approved, and, at the gate, no change without a record and a merged head that contains exactly the approved commit. Inert until adopted |
58
+ | `delivery` | the review record of each tranche under `docs/delivery/tranches/`: allowed author and reviewer models with recorded fallbacks, reviewer independence, every finding fixed, withdrawn after one dispute or ruled on by a human, every configured stage approved, and, at the gate, no change without a record and a merged head that contains exactly the approved commit. Inert until adopted |
59
59
  | `docs` | the documentation capability, including the generated journey view |
60
60
  | `coder`, `tester`, `security`, `architecture`, `metrics`, `runtime` | Bun source and test conventions |
61
61
  | `interlocking` | train/interlocking binding, infrastructure and route coverage |
@@ -115,12 +115,36 @@ For programs delivered as tranches by a coordinator and persistent drivers, with
115
115
  and independent reviewers. Adopt it by naming `delivery` in `profiles:` (or, with no list, by adding
116
116
  a `delivery:` block); `agent init` then installs the delivery skill and its review contract. The
117
117
  adopting pull request is itself governed: it changes files outside the delivery root, so it carries
118
- its own tranche record, reviewed and `ready` like any other. Every key is optional; these are the
119
- defaults:
118
+ its own tranche record, reviewed and `ready` like any other.
119
+
120
+ The records live with the program's reasoning, in the docs profile's `docs/delivery/` area:
121
+
122
+ ```text
123
+ docs/delivery/index.adoc the program: why, scope, how it was split (docs profile)
124
+ docs/delivery/tranches/<tranche>/evidence.yaml one tranche's review record (delivery profile)
125
+ docs/delivery/tranches/<tranche>/*.json the retained raw reviewer reports
126
+ ```
127
+
128
+ Where delivery is adopted, the docs profile leaves the records folder's records and data files to
129
+ the delivery profile: they are not authored documentation, and changing them needs no docs
130
+ declaration. AsciiDoc there stays documentation. Where the docs profile is active, the first tranche
131
+ also brings `docs/index.adoc` and `docs/delivery/index.adoc`, each with `:doc-id:` and `:status:`,
132
+ since every docs area needs an index.
133
+
134
+ Upgrading from 0.8.0: records under `delivery/` are reported until `delivery.root: delivery` is set
135
+ (a reported root change, approved once) or they are moved. A data file in an old tranche that no
136
+ record there names is reported on local runs, not at the merge gate; removing it is a gate change a
137
+ human approves, or a new tranche's record can name it.
138
+
139
+ Upgrading from 0.9.0: an `atdd-bun.yaml` field with the wrong type (a quoted number, `yes`/`no`, a
140
+ non-string list item, `.inf`) is now reported, and on the base branch it blocks every pull request,
141
+ since the policy cannot be compared. Correct such fields on the base branch before upgrading.
142
+
143
+ Every key is optional; these are the defaults:
120
144
 
121
145
  ```yaml
122
146
  delivery:
123
- root: delivery # one <tranche>/evidence.yaml per tranche, reports beside it
147
+ root: docs/delivery/tranches # one <tranche>/evidence.yaml per tranche, reports beside it
124
148
  require_record: true # at the gate, a change outside the root needs a tranche record
125
149
  multiplexer: herdr # the terminal multiplexer agents run in; any command name
126
150
  independence: fresh-process # or different-model; overridable per stage
@@ -4,7 +4,7 @@ kind: rule
4
4
  status: active
5
5
  name: The delivery policy in atdd-bun.yaml is well-formed
6
6
  statement: >-
7
- The `delivery:` block of atdd-bun.yaml validates against delivery-config.schema.json: known keys only, each stage listing at least one reviewer, models named in lowercase, independence one of fresh-process or different-model, the root in canonical form (no leading, trailing or doubled slash, no dot segment) and not overlapping the plan, source, test, e2e or telemetry root (REQUIRED).
7
+ The `delivery:` block of atdd-bun.yaml validates against delivery-config.schema.json: known keys only, each stage listing at least one reviewer, models named in lowercase, independence one of fresh-process or different-model, the root in canonical form (no leading, trailing or doubled slash, no dot segment) not overlapping the plan, source, test, e2e or telemetry root, and, inside docs/, exactly docs/delivery/tranches (REQUIRED).
8
8
  terms:
9
9
  - term_id: policy
10
10
  text: >-
@@ -18,7 +18,7 @@ content:
18
18
  Correct the key the finding names. The defaults are:
19
19
 
20
20
  delivery:
21
- root: delivery
21
+ root: docs/delivery/tranches
22
22
  independence: fresh-process
23
23
  stages:
24
24
  plan_review: { authors: [codex], reviewers: [glm, claude] }
@@ -4,7 +4,7 @@ kind: rule
4
4
  status: active
5
5
  name: Every tranche folder holds a well-formed evidence record
6
6
  statement: >-
7
- Every folder under the delivery root holds an evidence.yaml that validates against delivery-evidence.schema.json, whose tranche matches the folder name, whose reviews each name an author and a configured stage, and whose reports are data files (json, jsonl, yaml, yml, txt, md, log) inside the tranche's own folder, one per review; a ready record names one for every review (delivery.stages-complete) (REQUIRED).
7
+ Every folder under the delivery root holds an evidence.yaml that validates against delivery-evidence.schema.json, whose tranche matches the folder name, whose reviews each name an author and a configured stage, and whose reports are data files (json, jsonl, yaml, yml, txt, md, log) inside the tranche's own folder, one per review; a ready record names one for every review (delivery.stages-complete); and the records folder holds nothing else: a data file belongs only as a report a record in its tranche names, and every other file, including a loose file directly under the root, is a finding (REQUIRED).
8
8
  terms:
9
9
  - term_id: tranche
10
10
  text: >-
@@ -21,7 +21,7 @@ content:
21
21
  Create or repair `<root>/<tranche>/evidence.yaml`; the finding names the schema path at fault. A request-changes review carries at least one finding; every review carries its `checked` list.
22
22
  exceptions:
23
23
  - >-
24
- Files other than evidence.yaml inside a tranche folder (retained raw reviewer reports) are not judged.
24
+ The default root, docs/delivery/tranches, sits in the docs profile's delivery area beside the program's reasoning (docs/delivery/index.adoc); the docs profile leaves the folder's record and data files to this profile, and AsciiDoc there stays documentation.
25
25
  metadata:
26
26
  aliases:
27
27
  - DELIVERY-EVIDENCE-SCHEMA-001
@@ -4,7 +4,7 @@ kind: rule
4
4
  status: active
5
5
  name: A tranche merges only its approved SHA
6
6
  statement: >-
7
- At the gate (ATDD_DELIVERY_GATE, which the generated CI sets to merge on pull requests and the merge queue and to post-merge on pushes to the protected branches), every evidence record the change touches is ready and approves a commit the head contains; before the merge the head differs from that commit only by the record and the reports it names; no record is deleted, and no record or report already on the base branch is modified; under the delivery root only records and the reports a changed record names change; and, with require_record (the default), a change outside the delivery root comes with a tranche record (REQUIRED).
7
+ At the gate (ATDD_DELIVERY_GATE, which the generated CI sets to merge on pull requests and the merge queue and to post-merge on pushes to the protected branches), every evidence record the change touches is ready and approves a commit the head contains; before the merge the head differs from that commit only by the record and the reports it names; no record is deleted, and no record or report already on the base branch is modified (records 0.8.0 kept under delivery/ included, after the root moves); under the delivery root only records and the reports a changed record names change; and, with require_record (the default), a change outside the delivery root comes with a tranche record (REQUIRED).
8
8
  terms:
9
9
  - term_id: merge_gate
10
10
  text: >-
@@ -45,6 +45,7 @@ content:
45
45
  migration preserves history and never promotes it.
46
46
  exceptions:
47
47
  - Everything beneath `docs/dist/` is generated output and is never scanned.
48
+ - Where the delivery profile is adopted, its records folder (delivery.root, by default `docs/delivery/tranches/`) holds tranche records and raw reviewer reports, not authored documentation; its record and data files are left to that profile, while AsciiDoc there stays documentation.
48
49
  - Markdown OUTSIDE `docs/` is untouched by this rule. `README.md`, `CONTRIBUTING.md` and the
49
50
  repository-root corpus are out of its scope; they are migrated by the adoption step, not
50
51
  rejected by this detector.
@@ -50,6 +50,7 @@ content:
50
50
  the honest correction.
51
51
  exceptions:
52
52
  - '`docs/dist/**` is generated output and is never required to be declared.'
53
+ - Where the delivery profile is adopted, its records folder (delivery.root, by default `docs/delivery/tranches/`) changes with every tranche; its record and data files are governed by that profile's merge gate, not by a documentation declaration (AsciiDoc there is still documentation and still declared).
53
54
  - A repository with no documentation declaration recorded at all is core's concern (companion §4,
54
55
  check 1), not this rule's. This rule fires when a declaration exists and does not cover the diff.
55
56
  - >-
package/integrity.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
- "version": "0.8.1",
2
+ "version": "0.9.1",
3
3
  "files": {
4
4
  "HOOK_AUDIT.md": "5329d840db37671b1918f688ead26865473b87db73dbc75f7c8b2a8bbe8d6d43",
5
5
  "PLANNER_PORT.md": "fb5935bac8b7ac18994de21e43ace3a5ef8cd55f85b0e3349fca261280054f11",
6
- "README.md": "e39b94cf795cd3b50b9ee88283beaef1e4d73568a5da2cf2959ea02a98d97afb",
6
+ "README.md": "bd4749f81d83b92e0154692f3ee8e926b0c05ba3fbb41b430da03d56d72d7508",
7
7
  "bunfig.toml": "b9fc65eca9014c5179380259d70e76385a6a80788fa9a2df5fb3eaa5554fd2fe",
8
8
  "conventions/atdd-bun.planner/atdd-bun.planner.acceptance-identity.convention.yaml": "81c5c773d5ee15e8d99a2c237aa9846b110b2533df45ebf407d0e3ec6ed3dd03",
9
9
  "conventions/atdd-bun.planner/atdd-bun.planner.identity-required.convention.yaml": "e96d7c1455d0c221072d82e7f2b55da1c4f9ceb17718eb6ed96affdeef37675b",
@@ -92,23 +92,23 @@
92
92
  "conventions/coder.htmx/coder.htmx.verb-endpoint-same-origin.convention.yaml": "2128d26153e2d1f19c65a1e1665d4f82c78395daedb336d6ef1256e82b74aedb",
93
93
  "conventions/coder.htmx/coder.htmx.verb-mutation-signals-progress.convention.yaml": "ae85c5ea01a09a3c41b939988088a8e5da91848d2710a1f0a1e0c86308638ac9",
94
94
  "conventions/delivery/delivery.approved-sha-resolves.convention.yaml": "8bad8a0fdb28c4dc5f22b8949c8113eec4cc7b18b18af8c12ead5e3e320384ac",
95
- "conventions/delivery/delivery.config-schema.convention.yaml": "da60ccaed20fb8c77eb8adeb2999158ac3790c6bd28f2e2b2f69e75badbc34fa",
96
- "conventions/delivery/delivery.evidence-schema.convention.yaml": "ecd838a69c53a84018bfc937dce5a80877f5251172b498e418e7d1d3db46ea43",
95
+ "conventions/delivery/delivery.config-schema.convention.yaml": "6c37e1eef9d955f8dadd1308c9641fa5842529d207e7576284a3bc21a076da71",
96
+ "conventions/delivery/delivery.evidence-schema.convention.yaml": "d835dc07f029ffa01ad2dd5b129c45863efb2883b7fe0139eef8ac0bce68cc08",
97
97
  "conventions/delivery/delivery.findings-resolved.convention.yaml": "f6574e93a9a2d4bdd6d6f5645720b1a647fc0048d95f7675fe783d2ddb62c05b",
98
- "conventions/delivery/delivery.merge-gate.convention.yaml": "dab8c893999d13798d99a9b3ecd350f47de651365bc9d05b9be57a87aecf84ce",
98
+ "conventions/delivery/delivery.merge-gate.convention.yaml": "5961a599a750bd0137f10a25b69b4ba8210c5d3022a02c258c100c4716553172",
99
99
  "conventions/delivery/delivery.model-allowed.convention.yaml": "0476fb4a00dfdfbc16ac3c5843d7796d03e9d7c3ebd2c898eddb47160ac2fcdb",
100
100
  "conventions/delivery/delivery.reviewer-independent.convention.yaml": "f3388fe27a0b50549c913ab56a3afda7b70e54f8d0c32146ff59bd9da76afcfc",
101
101
  "conventions/delivery/delivery.stages-complete.convention.yaml": "1fd8c0a57e8ee2f91104088c25ee2000fb83e15b58a0cc5c432b37fa0457d056",
102
102
  "conventions/planner.docs/planner.docs.adr-registry-derived.convention.yaml": "0ea4232beba90280c664f93d097cafa44c67c2919c1468d4823fdc9e686aa5d2",
103
103
  "conventions/planner.docs/planner.docs.area-index-required.convention.yaml": "fd931e9f225bfbb5e78436d0d8a2a89cc0f5114493579962c72dd971301becce",
104
104
  "conventions/planner.docs/planner.docs.artifact-path-shape.convention.yaml": "a9f6178d29abe9385342a46a2c57470a3631aaf5e26f01e3660b7088b042418e",
105
- "conventions/planner.docs/planner.docs.asciidoc-only.convention.yaml": "6e7b5b16efd794bee677d0827adc71c2f66df8bcba0264dd74a25c67c19f3a3b",
105
+ "conventions/planner.docs/planner.docs.asciidoc-only.convention.yaml": "9e6fadbbe9aaf6015afc479f5b392e4c4324bfc47e1c59e315d26c2e5180a5f8",
106
106
  "conventions/planner.docs/planner.docs.doc-id-unique.convention.yaml": "558bc7d7001645f5443fcf19a5da67df17220b335d99811d103529bf22555311",
107
107
  "conventions/planner.docs/planner.docs.graph-target-resolves.convention.yaml": "657225233b92125b2667e2d72d65dc0ebc7cfc92b2c9e023d3d42449281683ea",
108
108
  "conventions/planner.docs/planner.docs.identity-required.convention.yaml": "86427664a497d7fefc8f77178cf64db66cb7ce328c1a3d2b94a0109ee7401e86",
109
109
  "conventions/planner.docs/planner.docs.journey-view-current.convention.yaml": "5892d4213d72342dc568c37f13942139bbdce4c0d5716075de579dc8060fc969",
110
110
  "conventions/planner.docs/planner.docs.reference-integrity.convention.yaml": "34310088114ac7cc58bc74443a76366e3e644121aaa3ff39b6602a0d15e03293",
111
- "conventions/planner.docs/planner.docs.undeclared-change.convention.yaml": "102a0706b00e074a74dbe38a6a389075929aa12bc38dcfb155d7a5d52afe6abc",
111
+ "conventions/planner.docs/planner.docs.undeclared-change.convention.yaml": "eb0df8fbf343ed38fe4694f63a065ee3e0e060d819f57ca8ea2f151aef66a8d5",
112
112
  "conventions/planner.telemetry/planner.telemetry.acceptance-decision.convention.yaml": "67dc840e0993c6940a431403b47439484ed557855a7dee82d75abd1479f3151e",
113
113
  "conventions/planner.telemetry/planner.telemetry.logical-ownership.convention.yaml": "6dd666a22fd7fd0cb6e5fe5505665c8889a504ee929fdebf3aee019df4aac2b4",
114
114
  "conventions/planner.telemetry/planner.telemetry.metric-cardinality.convention.yaml": "e61831f207ad4f09be983b300cbedc40f6ad08a4b2483612fc195e2343be93f6",
@@ -665,12 +665,12 @@
665
665
  "detectors/delivery_evidence/atdd.implementation.yaml": "2f6fc30dc353bef97bcb32ea54e4317d3c87cbd7ae1443fcef2aefc46280dc09",
666
666
  "detectors/delivery_evidence/detect.mjs": "e98664dee8090a0b280cd285a54e9a6fb9ba3572af2666b4e3656ca5b0555509",
667
667
  "detectors/delivery_evidence/fixtures/clean/atdd-bun.yaml": "f701861e3530bb5a25a70d73e248549fb96642f48db1ff4d3e8e4546aaa825ee",
668
- "detectors/delivery_evidence/fixtures/clean/delivery/api/evidence.yaml": "2cdbc4f628cf2d56627e6692b1f4e1a9b72316a3b7d09b47fffcd54c696a0fcb",
668
+ "detectors/delivery_evidence/fixtures/clean/docs/delivery/tranches/api/evidence.yaml": "2cdbc4f628cf2d56627e6692b1f4e1a9b72316a3b7d09b47fffcd54c696a0fcb",
669
669
  "detectors/delivery_evidence/fixtures/dirty/atdd-bun.yaml": "85c572d65f27b11b7ff8f115883913b2d2f1978097c416d3341b9c9d0de373d4",
670
- "detectors/delivery_evidence/fixtures/dirty/delivery/api/evidence.yaml": "2b848f160a401a217f56d53d87ce8e0a0efc0ed6d3781f6cc746e384c93eb8cc",
671
- "detectors/delivery_evidence/fixtures/dirty/delivery/data/evidence.yaml": "165dd67ff64f14c30268de5b18bf0c9c6634fb9ca48439c660c1962cebe3e0cb",
672
- "detectors/delivery_evidence/fixtures/dirty/delivery/orphan/README.md": "7d480b1fff6546615e55d866784bc2267d06d0221dfde8d0ba0353fa1ea0db9a",
673
- "detectors/delivery_evidence/fixtures/dirty/delivery/ui/evidence.yaml": "777b4f7921e302dfce79cd764bba8b44091ac9c7ee04a00f5dbb663342ec68a0",
670
+ "detectors/delivery_evidence/fixtures/dirty/docs/delivery/tranches/api/evidence.yaml": "2b848f160a401a217f56d53d87ce8e0a0efc0ed6d3781f6cc746e384c93eb8cc",
671
+ "detectors/delivery_evidence/fixtures/dirty/docs/delivery/tranches/data/evidence.yaml": "165dd67ff64f14c30268de5b18bf0c9c6634fb9ca48439c660c1962cebe3e0cb",
672
+ "detectors/delivery_evidence/fixtures/dirty/docs/delivery/tranches/orphan/README.md": "7d480b1fff6546615e55d866784bc2267d06d0221dfde8d0ba0353fa1ea0db9a",
673
+ "detectors/delivery_evidence/fixtures/dirty/docs/delivery/tranches/ui/evidence.yaml": "777b4f7921e302dfce79cd764bba8b44091ac9c7ee04a00f5dbb663342ec68a0",
674
674
  "detectors/htmx_e2e_detector/atdd.implementation.yaml": "454bb123039d57c992d27d9274e20fa3452ac41a237eb55471be62a4bcfebf06",
675
675
  "detectors/htmx_e2e_detector/checks/_e2e.mjs": "4e3605500ac232603083790c3984de5c828028c60eef366b26aba18d38fce30b",
676
676
  "detectors/htmx_e2e_detector/checks/_map.json": "94dec08ff4764cd6a3dba139f12a7c984292e95a9354b0cdaa0ce9d4180e2c89",
@@ -1172,7 +1172,7 @@
1172
1172
  "planner-schemas/binding-lock.schema.json": "f2455606702f45fafa194549126d4a66e6c286ba1c4ba9e6bdcf932286a6d34d",
1173
1173
  "planner-schemas/component.schema.json": "d20d3502c155627a120cb10d5e9bdec4ba2c73af9c3e9a9d10e3c351f167c72a",
1174
1174
  "planner-schemas/contract-registry.schema.json": "b9dbaf6c87d4f4d676e3b4251344b9eada523b1c5e739c0423cabe267012c12e",
1175
- "planner-schemas/delivery-config.schema.json": "34e46c305b85340844086e60b2f5cbc1105ecc03ca63e5f9e494eff62353d30d",
1175
+ "planner-schemas/delivery-config.schema.json": "15b70a67c0725b441e6b9d428e9ccd76c06f4ee6d36406050893b70ecc130841",
1176
1176
  "planner-schemas/delivery-evidence.schema.json": "9b084eb73752d5637ba1d429f3709f8795cc2860c21c0b9bd58751b9887b001c",
1177
1177
  "planner-schemas/feature.schema.json": "eb4aa40d89067ae5c86a079bb3d8e4f45c8727f9e15db2b86c08dacf8e4ae56a",
1178
1178
  "planner-schemas/journey.schema.json": "1ff705bcf89a2823cf60c9e1597e254f97844e7a74dc58299d53192ad2c9f8c3",
@@ -1190,12 +1190,12 @@
1190
1190
  "src/agent.ts": "b2015873940ca83bb8fdc683dbb85c2d53fe6f8c3e2b6b0b71bda895f0c1eb2f",
1191
1191
  "src/ci.ts": "67e54d2cbfc44a9e42837d5e750af526d6255c539d3c452a4d5fca3226c0d9ac",
1192
1192
  "src/cli.ts": "bb2047cee60f48bfe3eaee4c1b3dcd3538a0e1edc170cf58e87686117bea07a7",
1193
- "src/delivery.ts": "7962144972ae1f853a25cd88cf0cb7d4bca247b1193cd5eb33acc1ef272072cb",
1194
- "src/docs-capability.ts": "0feae94b7697dd53d8a457b24765e81fcf804c774f0cc4b6035cb3da822e486a",
1193
+ "src/delivery.ts": "8c2f964a4acc4471b6541f980c9c6f270b33cd64c95f5fe852a401060d507975",
1194
+ "src/docs-capability.ts": "115cf27049a5cf19c133bac5ec237072be6f969a59a2ef6767e054108a21dc5c",
1195
1195
  "src/enforce.ts": "58ad0593895912bdc1effe97430b07acf8cda066052397957558cbb331c16778",
1196
1196
  "src/hooks.ts": "adbb72f7a83f53d596fd217f01f4ce32c5a59a23ab81993fed226fb96fd1cbd1",
1197
1197
  "src/index.ts": "8ee4d7716794f6990ae8580e471b98256a0d0223dad063cd3b4542e598b6e744",
1198
- "src/integrity.ts": "7cbb1ad4991079a4852d2d131c0769f66e13dc8b5fa0e637780a1b70a75eaf78",
1198
+ "src/integrity.ts": "ac516b1d3a73f872d0ed2b20a27ef0ac14ecc015293bd8e51954f322f8a063bb",
1199
1199
  "src/journey-docs.ts": "994d229376244c84212cff951032823068a76565dd2fd427b9d44e341bd14853",
1200
1200
  "src/planner-kernel.ts": "5d3f5305fb59becb2cc97d8a03aca6b104f385805d91cc0410856e289dd6fbb1",
1201
1201
  "src/planner-schema-validator.ts": "ce529c0936171075e93c7539b3655fe895a98dd2973ebda5775958e6ebc316d9",
@@ -1208,7 +1208,7 @@
1208
1208
  "templates/agents/AGENTS.block.md": "7e9687b1eff245b66da4127b336a9e20af2f5fa273e08895188c0a0dffebd77d",
1209
1209
  "templates/agents/atdd-bun.integrity.test.ts": "dec6f6e5f65a08d9512fc703b367c163f8c9da1f6aa7fd466835c1ec30348a41",
1210
1210
  "templates/agents/atdd/SKILL.md": "b621abe22851a75b30fc0e6c339a2c0a3ed6bbf78b8c17000a4ce301e05df96d",
1211
- "templates/agents/delivery/SKILL.md": "72c279fba45c867d0a5b5e02f34dcba89efe06dfc9230c7aad501d2ae3629c35",
1211
+ "templates/agents/delivery/SKILL.md": "9d9b80394929220f6ec1521137df02976e3240601bbc92ee6513e078442856a6",
1212
1212
  "templates/agents/delivery/review.md": "289ee5d55ba0f321e5703a6fd8d5417d55bf0d9c6dc3103ea0f79af11c159d9c",
1213
1213
  "templates/github/atdd-bun-release.yml": "d9547e9e6ef3ae010d53d55314f6a54567f88bd900f7e50d994cd308601d2684",
1214
1214
  "templates/github/atdd-bun.yml": "bb42414f72f4b9a2fb1eb69530695e193f105a984542f4ca124c898a5f1489be"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-bun",
3
- "version": "0.8.1",
3
+ "version": "0.9.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/afokapu/atdd-bun.git"
@@ -9,7 +9,7 @@
9
9
  "root": {
10
10
  "type": "string",
11
11
  "pattern": "^[a-z0-9_][a-z0-9_.-]*(?:/[a-z0-9_][a-z0-9_.-]*)*$",
12
- "description": "Repository-relative folder holding one <tranche>/evidence.yaml per tranche, in canonical form: no leading, trailing or doubled slash, no dot segments. Default: delivery. Changing it after adoption is a loosening the integrity check reports."
12
+ "description": "Repository-relative folder holding one <tranche>/evidence.yaml per tranche, in canonical form: no leading, trailing or doubled slash, no dot segments. Default: docs/delivery/tranches, beside the program's reasoning in the docs profile's delivery area. Changing it after adoption is a loosening the integrity check reports."
13
13
  },
14
14
  "require_record": {
15
15
  "type": "boolean",
package/src/delivery.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import Ajv, { type ValidateFunction } from "ajv";
2
2
  import addFormats from "ajv-formats";
3
- import { existsSync, lstatSync } from "node:fs";
3
+ import { existsSync, lstatSync, realpathSync, statSync } from "node:fs";
4
4
  import { readdir, readFile } from "node:fs/promises";
5
5
  import { join, resolve } from "node:path";
6
6
  import type { PlanFinding } from "./planner-kernel";
@@ -39,6 +39,12 @@ const DEFAULT_STAGES: Record<Stage, Omit<StagePolicy, "independence">> = {
39
39
  code_review: { authors: ["glm", "claude"], reviewers: ["glm", "claude"] },
40
40
  final_review: { authors: ["codex"], reviewers: ["codex", "claude"] },
41
41
  };
42
+ /** Tranche records live with the program's reasoning, under the docs profile's delivery area; the docs profile leaves
43
+ * this folder to the delivery profile (records are YAML and data, never authored AsciiDoc). */
44
+ export const DEFAULT_ROOT = "docs/delivery/tranches";
45
+ /** The default before 0.9.0. */
46
+ const LEGACY_ROOT = "delivery";
47
+ const inDocs = (root: string) => root === "docs" || root.startsWith("docs/");
42
48
  const DEFAULT_FALLBACK: DeliveryPolicy["fallback"] = { after_failures: 3, within_minutes: 10, when_exhausted: "block" };
43
49
  const packageRoot = resolve(import.meta.dir, "..");
44
50
 
@@ -71,14 +77,14 @@ export function deliveryPolicy(block: unknown): DeliveryPolicy {
71
77
  }
72
78
  const fallback = record(raw.fallback) ?? {};
73
79
  return {
74
- root: canonicalRoot(text(raw.root, "delivery")), independence, stages,
80
+ root: canonicalRoot(text(raw.root, DEFAULT_ROOT)), independence, stages,
75
81
  fallback: { after_failures: count(fallback.after_failures, DEFAULT_FALLBACK.after_failures), within_minutes: count(fallback.within_minutes, DEFAULT_FALLBACK.within_minutes), when_exhausted: fallback.when_exhausted === "wait" ? "wait" : "block" },
76
82
  commands: (record(raw.commands) ?? {}) as DeliveryPolicy["commands"], require_record: raw.require_record === false ? false : true, multiplexer: text(raw.multiplexer, "herdr"),
77
83
  };
78
84
  }
79
85
 
80
86
  /** One spelling per root, so filesystem discovery, Git pathspecs and drift filtering agree ("delivery/" is "delivery"). */
81
- export const canonicalRoot = (root: string) => root.replaceAll("\\", "/").split("/").filter(part => part && part !== ".").join("/") || "delivery";
87
+ export const canonicalRoot = (root: string) => root.replaceAll("\\", "/").split("/").filter(part => part && part !== ".").join("/") || DEFAULT_ROOT;
82
88
 
83
89
  /** Why `current` enforces less than `base`, for the integrity check's loosening report. Tightening is silent. */
84
90
  export function loosenedDelivery(base: unknown, current: unknown): string[] {
@@ -102,7 +108,9 @@ export function loosenedDelivery(base: unknown, current: unknown): string[] {
102
108
  if (promoted.length) out.push(`delivery.stages.${stage}.${role} [${b[role].join(", ")}] → [${c[role].join(", ")}] promotes ${promoted.join(", ")}`);
103
109
  }
104
110
  }
105
- // Moving the root hides every earlier record from the validator and the gate.
111
+ // Moving the root hides every earlier record from the validator and the gate. No exception, not even pinning the 0.8.0
112
+ // default: from the config alone it cannot be told apart from moving a 0.9 repository's records out of view, so a
113
+ // human approves it.
106
114
  if (before.root !== after.root) out.push(`delivery.root ${before.root} → ${after.root}`);
107
115
  if (before.require_record && !after.require_record) out.push("delivery.require_record true → false");
108
116
  if (before.fallback.when_exhausted === "block" && after.fallback.when_exhausted === "wait") out.push("delivery.fallback.when_exhausted block → wait");
@@ -140,10 +148,44 @@ type Finding = { id: string; severity: string; rebuttal?: string; outcome?: "fix
140
148
  type Evidence = { tranche: string; status: "open" | "ready"; base_sha: string; approved_sha?: string; reviews: Review[] };
141
149
  export type EvidenceFile = { file: string; tranche: string; data: Evidence | null; error?: string };
142
150
 
151
+ /** Files in the records folder that are neither a tranche's evidence.yaml nor a data file (a report): authored documents
152
+ * or code would otherwise sit in a folder no other profile judges. Loose files directly under the root count too. */
153
+ export async function strayFiles(root: string, policy: DeliveryPolicy): Promise<string[]> {
154
+ const dir = join(root, policy.root), out: string[] = [];
155
+ if (!isFolder(dir)) return out;
156
+ const walk = async (path: string, depth: number): Promise<void> => {
157
+ for (const entry of await readdir(path, { withFileTypes: true })) {
158
+ const child = join(path, entry.name), rel = `${policy.root}/${child.slice(dir.length + 1).replaceAll("\\", "/")}`;
159
+ if (entry.isSymbolicLink() && depth === 0) continue; // a symlinked tranche folder is reported by loadEvidence
160
+ if (entry.isSymbolicLink()) { out.push(rel); continue; } // a symlinked file points outside what any rule judges
161
+ if (entry.isDirectory()) await walk(child, depth + 1);
162
+ else if (depth === 0 || !(entry.name === "evidence.yaml" && depth === 1 || REPORT_EXTENSION.test(entry.name))) out.push(rel);
163
+ }
164
+ };
165
+ await walk(dir, 0);
166
+ return out.sort();
167
+ }
168
+
169
+ /** Data files inside tranche folders (depth 1 and below, other than evidence.yaml): candidate reports. */
170
+ async function dataFiles(root: string, policy: DeliveryPolicy): Promise<string[]> {
171
+ const dir = join(root, policy.root), out: string[] = [];
172
+ if (!isFolder(dir)) return out;
173
+ const walk = async (path: string, depth: number): Promise<void> => {
174
+ for (const entry of await readdir(path, { withFileTypes: true })) {
175
+ const child = join(path, entry.name);
176
+ if (entry.isSymbolicLink()) continue;
177
+ if (entry.isDirectory()) await walk(child, depth + 1);
178
+ else if (depth >= 1 && !(entry.name === "evidence.yaml" && depth === 1) && REPORT_EXTENSION.test(entry.name)) out.push(`${policy.root}/${child.slice(dir.length + 1).replaceAll("\\", "/")}`);
179
+ }
180
+ };
181
+ await walk(dir, 0);
182
+ return out;
183
+ }
184
+
143
185
  /** Every tranche folder under the delivery root with its parsed evidence, or why it has none. */
144
186
  export async function loadEvidence(root: string, policy: DeliveryPolicy): Promise<EvidenceFile[]> {
145
187
  const dir = join(root, policy.root);
146
- if (!existsSync(dir)) return [];
188
+ if (!isFolder(dir)) return [];
147
189
  const out: EvidenceFile[] = [];
148
190
  for (const entry of (await readdir(dir, { withFileTypes: true })).filter(entry => entry.isDirectory() || entry.isSymbolicLink()).sort((a, b) => a.name.localeCompare(b.name))) {
149
191
  // A symlinked tranche folder could point anywhere, and would otherwise be skipped unread.
@@ -181,6 +223,20 @@ function checkModels(file: string, review: Review, policy: StagePolicy, at: stri
181
223
 
182
224
  /** Reports are data, never code: a report path is exempt from drift, so it must not be able to name a source file. */
183
225
  const REPORT_EXTENSION = /\.(json|jsonl|yaml|yml|txt|md|log)$/;
226
+ /** The reports a record names, read defensively: this runs before schema validation, so a malformed record (reviews not
227
+ * a list, an entry not a mapping, a report not a string) names nothing and is left to the schema rule to report. */
228
+ const namedReports = (data: unknown): string[] => {
229
+ const reviews = record(data)?.reviews;
230
+ return Array.isArray(reviews) ? reviews.flatMap(review => { const report = record(review)?.report; return typeof report === "string" && report ? [report] : []; }) : [];
231
+ };
232
+ const isFolder = (path: string) => { try { return lstatSync(path).isDirectory(); } catch { return false; } };
233
+ /** A folder, through a symlink too: the legacy and hidden-records probes look wherever records could be. */
234
+ const isLink = (path: string) => { try { return lstatSync(path).isSymbolicLink(); } catch { return false; } };
235
+ /** Whether any folder on the way to `relative` (below `root`) is a symlink. */
236
+ const throughLink = (root: string, relative: string) => relative.split("/").some((_, i, parts) => isLink(join(root, ...parts.slice(0, i + 1))));
237
+ /** Two paths that resolve to one folder (a compatibility symlink to the root): its records are the root's, not outside it. */
238
+ const sameFolder = (a: string, b: string) => { try { return realpathSync(a) === realpathSync(b); } catch { return false; } };
239
+ const reachesFolder = (path: string) => { try { return statSync(path).isDirectory(); } catch { return false; } };
184
240
  const regularFile = (path: string) => { try { return lstatSync(path).isFile(); } catch { return false; } };
185
241
 
186
242
  /** Two spellings of one commit: an abbreviated SHA is a prefix of the full one. */
@@ -296,7 +352,22 @@ export async function validateDelivery(root = process.cwd(), options: DeliveryOp
296
352
  const overlap = owned.find(other => policy.root === other || policy.root.startsWith(`${other}/`) || other.startsWith(`${policy.root}/`));
297
353
  if (overlap) {
298
354
  findings.push(finding("delivery.config-schema", "atdd-bun.yaml", `delivery.root ${policy.root} overlaps the ${overlap} root; the delivery root holds only records and reports`));
299
- policy = { ...policy, root: "delivery" };
355
+ policy = { ...policy, root: DEFAULT_ROOT };
356
+ } else if (inDocs(policy.root) && policy.root !== DEFAULT_ROOT) {
357
+ // The docs profile gives up exactly one folder under docs/; any other root there would take authored documentation
358
+ // out of its rules.
359
+ findings.push(finding("delivery.config-schema", "atdd-bun.yaml", `delivery.root ${policy.root} is inside docs/; the only delivery root there is ${DEFAULT_ROOT}, the one folder the docs profile leaves to delivery`));
360
+ policy = { ...policy, root: DEFAULT_ROOT };
361
+ }
362
+ // 0.8.0 kept records under delivery/ by default. Records left there under any other effective root would be unseen.
363
+ if (policy.root !== LEGACY_ROOT && reachesFolder(join(absolute, LEGACY_ROOT)) && !sameFolder(join(absolute, LEGACY_ROOT), join(absolute, policy.root))) {
364
+ const legacy = (await readdir(join(absolute, LEGACY_ROOT), { withFileTypes: true })).filter(entry => entry.isDirectory() && existsSync(join(absolute, LEGACY_ROOT, entry.name, "evidence.yaml")));
365
+ if (legacy.length) findings.push(finding("delivery.config-schema", "atdd-bun.yaml", `tranche records under delivery/ (${legacy.map(entry => entry.name).join(", ")}) are outside the root ${policy.root}; 0.8.0 kept them there by default; set delivery.root: delivery (a root change the integrity check reports for a human to approve once), or move them there, which rewrites merged records and so needs a human-supervised merge`));
366
+ }
367
+ // The other direction: records under the default root while another root is configured are outside what is judged.
368
+ if (policy.root !== DEFAULT_ROOT && reachesFolder(join(absolute, DEFAULT_ROOT)) && !sameFolder(join(absolute, DEFAULT_ROOT), join(absolute, policy.root))) {
369
+ const hidden = (await readdir(join(absolute, DEFAULT_ROOT), { withFileTypes: true })).filter(entry => entry.isDirectory() && existsSync(join(absolute, DEFAULT_ROOT, entry.name, "evidence.yaml")));
370
+ if (hidden.length) findings.push(finding("delivery.config-schema", "atdd-bun.yaml", `tranche records under ${DEFAULT_ROOT} (${hidden.map(entry => entry.name).join(", ")}) are outside the configured root ${policy.root}; move them there, or remove delivery.root`));
300
371
  }
301
372
  const validEvidence = await schema("delivery-evidence.schema.json"), files = await loadEvidence(absolute, policy);
302
373
  const mode = options.gate === undefined ? gateMode() : options.gate === true ? "merge" : options.gate || null;
@@ -305,6 +376,17 @@ export async function validateDelivery(root = process.cwd(), options: DeliveryOp
305
376
  // fail every later change. Outside the gate every record is judged. The gate itself still rejects any change to
306
377
  // an untouched record's folder (mergeGate).
307
378
  const onBase = mode ? (await gateRange(absolute, mode, options.base))?.base ?? null : null;
379
+ // At the gate, like records, a stray the change does not touch was there when it merged and is not judged again.
380
+ // A data file belongs only as a report some record in its tranche names; an unnamed one is authored content by another name.
381
+ // Per tranche: a data file is a report only when a record in its own tranche names it.
382
+ const reported = new Set(files.flatMap(entry => namedReports(entry.data).filter(report => report.startsWith(`${policy.root}/${entry.tranche}/`))));
383
+ const unnamed = (await dataFiles(absolute, policy)).filter(path => !reported.has(path));
384
+ const rootPath = join(absolute, policy.root);
385
+ if (existsSync(rootPath) || isLink(rootPath)) {
386
+ if (isLink(rootPath) || throughLink(absolute, policy.root)) findings.push(finding("delivery.config-schema", "atdd-bun.yaml", `delivery.root ${policy.root} is, or is reached through, a symlink; the delivery root must be a real folder, whose records Git tracks`));
387
+ else if (!isFolder(rootPath)) findings.push(finding("delivery.config-schema", "atdd-bun.yaml", `delivery.root ${policy.root} is a file, not a folder; the records cannot be read`));
388
+ }
389
+ for (const path of [...await strayFiles(absolute, policy), ...unnamed].sort()) if (!onBase || (await git(absolute, ["diff", "--quiet", onBase, "--", path])).code) findings.push(finding("delivery.evidence-schema", path, `${path} is neither a tranche's evidence.yaml nor a report a record in its tranche names (a data file: ${REPORT_EXTENSION.source.slice(3, -2).replaceAll("|", ", ")}); the records folder holds only records and their reports`));
308
390
  for (const entry of files) {
309
391
  if (onBase && existsSync(join(absolute, entry.file)) && !(await git(absolute, ["diff", "--quiet", onBase, "--", `${policy.root}/${entry.tranche}`])).code) continue;
310
392
  if (!entry.data) { findings.push(finding("delivery.evidence-schema", entry.file, entry.error ?? "evidence is missing")); continue; }
@@ -376,9 +458,16 @@ async function mergeGate(root: string, policy: DeliveryPolicy, files: EvidenceFi
376
458
  // A record or report already on the base branch is final: editing an old record would make its reports "named by a
377
459
  // changed record" and so exempt from drift. A later change to a tranche is a new tranche with its own record.
378
460
  const final = (await git(root, ["diff", "--no-renames", "--relative", "--name-only", "--diff-filter=MRTC", ...span, "--", policy.root])).out.split("\n").filter(Boolean);
461
+ // Records 0.8.0 kept under delivery/ stay append-only after the root moves: deleting or editing one is reported even
462
+ // though it is outside the current root. Only exact <tranche>/evidence.yaml paths, so an unrelated delivery/ folder
463
+ // in the project is not touched.
464
+ if (policy.root !== LEGACY_ROOT) {
465
+ const legacy = (await git(root, ["diff", "--no-renames", "--relative", "--name-only", "--diff-filter=DMRTC", ...span, "--", LEGACY_ROOT])).out.split("\n").filter(path => /^delivery\/[^/]+\/evidence\.yaml$/.test(path));
466
+ for (const path of legacy) out.push(finding("delivery.merge-gate", path, `${mode === "merge" ? "the branch" : "this push"} ${existsSync(join(root, path)) ? "modifies" : "deletes"} ${path}, a record 0.8.0 kept under delivery/; records stay append-only when the root moves`));
467
+ }
379
468
  for (const path of final) out.push(finding("delivery.merge-gate", path, `${mode === "merge" ? "the branch" : "this push"} modifies ${path}, which is already on the base branch; merged records and reports are final, so a later change needs a new tranche`));
380
469
  // Under the root, only records and the reports a changed record names may change: anything else is unbound.
381
- const named = new Set(changed.flatMap(path => files.find(file => file.file === path)?.data?.reviews?.flatMap(review => review.report ? [review.report] : []) ?? []));
470
+ const named = new Set(changed.flatMap(path => namedReports(files.find(file => file.file === path)?.data)));
382
471
  for (const path of changedHere.filter(path => path.startsWith(`${policy.root}/`) && !isRecord(path) && !named.has(path)))
383
472
  out.push(finding("delivery.merge-gate", path, `${mode === "merge" ? "the branch" : "this push"} changes ${path} under ${policy.root}/, and no record it changes names it as a report; only <tranche>/evidence.yaml records and their reports live there`));
384
473
  if (policy.require_record && outside.length && !changed.length) out.push(finding("delivery.merge-gate", "atdd-bun.yaml", `${mode === "merge" ? "the branch" : "this push"} changes ${outside.slice(0, 5).join(", ")}${outside.length > 5 ? ` and ${outside.length - 5} more` : ""} with no tranche record under ${policy.root}/; every change merges through a reviewed tranche (delivery.require_record)`));
@@ -1,5 +1,6 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { journeyDocs, journeyDocsApply } from "./journey-docs";
3
+ import { DEFAULT_ROOT, deliveryAdopted, deliveryPolicy } from "./delivery";
3
4
  import { mkdtemp, readdir, readFile, rm } from "node:fs/promises";
4
5
  import { tmpdir } from "node:os";
5
6
  import { basename, isAbsolute, join, relative, resolve } from "node:path";
@@ -50,6 +51,23 @@ export function parseAttributes(text: string): { attrs: Record<string, string>;
50
51
  return { attrs, lines };
51
52
  }
52
53
 
54
+ /** The delivery profile's records folder, when it is adopted: tranche records and reports are YAML and data, judged by
55
+ * that profile, never authored documentation. The docs profile leaves the folder (by default docs/delivery/tranches) to
56
+ * it; the rest of docs/delivery, the program's reasoning, stays documentation. */
57
+ async function deliveryRecords(root: string): Promise<(path: string) => boolean> {
58
+ const file = join(root, "atdd-bun.yaml");
59
+ try {
60
+ const data = existsSync(file) ? Bun.YAML.parse(await readFile(file, "utf8")) as Record<string, unknown> | null : null;
61
+ if (!deliveryAdopted(data)) return () => false;
62
+ // Only the one folder the delivery profile may use under docs/; a root configured anywhere else in docs/ is a delivery
63
+ // finding and exempts nothing here.
64
+ if (deliveryPolicy(data!.delivery).root !== DEFAULT_ROOT) return () => false;
65
+ // Records and data only: AsciiDoc there stays documentation, judged by every docs rule (the delivery profile also
66
+ // reports it as a file that does not belong in the records folder).
67
+ return path => path.startsWith(`${DEFAULT_ROOT}/`) && !path.endsWith(".adoc");
68
+ } catch { return () => false; }
69
+ }
70
+
53
71
  async function documents(root: string): Promise<Document[]> {
54
72
  const paths = await walk(join(root, DOCS), ".adoc");
55
73
  return Promise.all(paths.filter(path => !generated(relative(root, path).replaceAll("\\", "/"))).map(async path => {
@@ -87,8 +105,8 @@ function adrViolations(docs: Document[]): DocumentationViolation[] {
87
105
  }
88
106
 
89
107
  export async function scanDocumentation(root: string): Promise<DocumentationViolation[]> {
90
- const absolute = resolve(root), docs = await documents(absolute), output: DocumentationViolation[] = [];
91
- for (const path of await walk(join(absolute, DOCS), ".md")) { const file = relative(absolute, path).replaceAll("\\", "/"); if (!generated(file)) output.push(violation("planner.docs.asciidoc-only", file, 1, `authored markdown beneath docs/: ${file}. AsciiDoc is the only authored format; convert it, and convert a historical document INTO docs/archive/ rather than into a current area.`, lineAt(await readFile(path, "utf8"), 1))); }
108
+ const absolute = resolve(root), docs = await documents(absolute), output: DocumentationViolation[] = [], records = await deliveryRecords(absolute);
109
+ for (const path of await walk(join(absolute, DOCS), ".md")) { const file = relative(absolute, path).replaceAll("\\", "/"); if (!generated(file) && !records(file)) output.push(violation("planner.docs.asciidoc-only", file, 1, `authored markdown beneath docs/: ${file}. AsciiDoc is the only authored format; convert it, and convert a historical document INTO docs/archive/ rather than into a current area.`, lineAt(await readFile(path, "utf8"), 1))); }
92
110
  for (const doc of docs) { const missing = ["doc-id", "status"].filter(name => !doc.attrs[name]); if (missing.length) output.push(violation("planner.docs.identity-required", doc.path, 1, `document declares no ${missing.map(name => `:${name}:`).join(" and no ")}. Identity and currency are both required: an id with no status is a node whose currency is unknown, a status with no id is a claim nothing can reference.`, lineAt(doc.text, 1))); }
93
111
  const byId = new Map<string, Document[]>(); for (const doc of docs) if (doc.attrs["doc-id"]) byId.set(doc.attrs["doc-id"], [...(byId.get(doc.attrs["doc-id"]) ?? []), doc]);
94
112
  for (const [id, group] of byId) if (group.length > 1) for (const doc of group) { const line = doc.lines["doc-id"] ?? 1; output.push(violation("planner.docs.doc-id-unique", doc.path, line, `doc-id ${JSON.stringify(id)} is declared by ${group.length} documents: ${group.map(d => d.path).join(", ")}. Resolution needs exactly one target per id.`, lineAt(doc.text, line))); }
@@ -106,12 +124,12 @@ async function journeyViewViolations(root: string): Promise<DocumentationViolati
106
124
  return result.stale.map(file => violation("planner.docs.journey-view-current", file, 1, `${file} does not match what plan/ generates, so the journey documentation no longer shows the plan. Regenerate it with \`atdd-bun docs journeys\` and commit the result; never edit it by hand.`));
107
125
  }
108
126
 
109
- export function declarationViolations(declaration: DocumentationDeclaration | null, changeSet?: string[]): DocumentationViolation[] {
127
+ export function declarationViolations(declaration: DocumentationDeclaration | null, changeSet?: string[], records: (path: string) => boolean = () => false): DocumentationViolation[] {
110
128
  if (!declaration) return [];
111
129
  const artifacts = Array.isArray(declaration.artifacts) ? declaration.artifacts : []; const output: DocumentationViolation[] = [];
112
130
  if (declaration.impact !== "change" && declaration.impact !== "none") output.push(violation("planner.docs.artifact-path-shape", "<declaration>", 1, `declaration carries impact=${JSON.stringify(declaration.impact)}; the two total forms are ["change", "none"]. A malformed declaration is reported, never treated as nothing-to-check.`));
113
131
  for (const [index, artifact] of artifacts.entries()) { const path = artifact.path ?? "", problems: string[] = []; if (!path) problems.push("declares no path"); else { if (!path.startsWith("docs/")) problems.push(`path ${JSON.stringify(path)} is outside the canonical tree (must begin "docs/")`); if (!path.endsWith(".adoc")) problems.push(`path ${JSON.stringify(path)} is not AsciiDoc (must end ".adoc")`); if (artifact.action === "archive" && !path.startsWith("docs/archive/")) problems.push(`archive destination ${JSON.stringify(path)} is outside "docs/archive/" — archiving must preserve history, never promote it into a current area`); } if (problems.length) output.push(violation("planner.docs.artifact-path-shape", path || "<declaration>", 1, `declared artifact[${index}] (action: ${artifact.action || "unset"}): ${problems.join("; ")}`)); }
114
- if (changeSet) { const covered = new Set(artifacts.flatMap(a => [a.path, a.from]).filter((p): p is string => Boolean(p))); for (const path of [...new Set(changeSet)].sort()) if (path.startsWith("docs/") && !generated(path) && !covered.has(path)) output.push(violation("planner.docs.undeclared-change", path, 1, `the change set touches ${path} and no declared artifact covers it. Declare it at RATIFY; if the change was not planned, the declaration was wrong at RATIFY and re-ratifying is the honest correction.`)); }
132
+ if (changeSet) { const covered = new Set(artifacts.flatMap(a => [a.path, a.from]).filter((p): p is string => Boolean(p))); for (const path of [...new Set(changeSet)].sort()) if (path.startsWith("docs/") && !generated(path) && !records(path) && !covered.has(path)) output.push(violation("planner.docs.undeclared-change", path, 1, `the change set touches ${path} and no declared artifact covers it. Declare it at RATIFY; if the change was not planned, the declaration was wrong at RATIFY and re-ratifying is the honest correction.`)); }
115
133
  return output;
116
134
  }
117
135
 
@@ -152,7 +170,7 @@ export async function renderDocumentation(root: string, timeoutMs = 120_000): Pr
152
170
  async function checkDocumentationInner(input: { root: string; declaration: DocumentationDeclaration | null; changeSet: string[] | null; render?: () => Promise<DocumentationRender> }): Promise<DocumentationCheck> {
153
171
  if (input.declaration?.impact === "none") return { verdict: "NOT_APPLICABLE", findings: [], checked: [] };
154
172
  const corpus = await scanDocumentation(input.root); const findings: DocumentationCheck["findings"] = [...corpus]; const checked = (await documents(resolve(input.root))).map(d => d.path); let definite = corpus.length > 0;
155
- if (input.declaration) { const declarationFindings = declarationViolations(input.declaration, input.changeSet ?? undefined); findings.push(...declarationFindings); definite ||= declarationFindings.length > 0; checked.push("<declaration>"); if (input.declaration.impact === "change") for (const artifact of input.declaration.artifacts ?? []) if (["create", "modify"].includes(artifact.action ?? "") && artifact.path && !existsSync(join(input.root, artifact.path))) { findings.push(violation("planner.docs.artifact-path-shape", artifact.path, 1, `declared artifact ${JSON.stringify(artifact.path)} (action: ${artifact.action}) is not in the tree, so it was never examined; a declared document that was never written has not discharged the obligation.`)); definite = true; } } else findings.push({ rule_id: null, where: "<declaration>", message: "core supplied no documentation declaration, so no declaration-dependent rule could be evaluated. This is COULD_NOT_CHECK and it BLOCKS." });
173
+ if (input.declaration) { const declarationFindings = declarationViolations(input.declaration, input.changeSet ?? undefined, await deliveryRecords(resolve(input.root))); findings.push(...declarationFindings); definite ||= declarationFindings.length > 0; checked.push("<declaration>"); if (input.declaration.impact === "change") for (const artifact of input.declaration.artifacts ?? []) if (["create", "modify"].includes(artifact.action ?? "") && artifact.path && !existsSync(join(input.root, artifact.path))) { findings.push(violation("planner.docs.artifact-path-shape", artifact.path, 1, `declared artifact ${JSON.stringify(artifact.path)} (action: ${artifact.action}) is not in the tree, so it was never examined; a declared document that was never written has not discharged the obligation.`)); definite = true; } } else findings.push({ rule_id: null, where: "<declaration>", message: "core supplied no documentation declaration, so no declaration-dependent rule could be evaluated. This is COULD_NOT_CHECK and it BLOCKS." });
156
174
  if (input.declaration && input.changeSet === null) findings.push({ rule_id: null, where: "<change_set>", message: "core supplied no change set, so whether this diff touches docs/ without declaring it could not be established. This is COULD_NOT_CHECK and it BLOCKS." });
157
175
  const rendered = await (input.render ?? (() => renderDocumentation(input.root)))(); findings.push(...rendered.findings); definite ||= rendered.findings.length > 0; if (rendered.couldNotCheck) findings.push({ rule_id: "planner.docs.reference-integrity", file: "docs/", line: 1, col: 1, evidence: rendered.couldNotCheck, source_line: "" }); else checked.push("<render:asciidoctor>");
158
176
  // Fail closed on any rule id this capability does not declare, however it was built (a renderer can return anything).
package/src/integrity.ts CHANGED
@@ -118,6 +118,36 @@ async function checkGenerated(root: string, packageRoot: string, skipWorkflow =
118
118
  * its absent-means-all default is right for execution and wrong for deciding whether a policy was ever declared. */
119
119
  const explicitProfiles = (config: { profiles?: unknown }): string[] | null => Array.isArray(config.profiles) ? config.profiles.map(String) : null;
120
120
 
121
+ /** A policy with null-valued hook keys (and worktrees children) removed: YAML gives null for a key with no value, and the
122
+ * hooks read null as absent, so the comparison must too. Other keys keep their null: delivery and profiles are read by
123
+ * readers that tell null apart from absent (`delivery:` with no value adopts delivery). */
124
+ function withoutNulls(config: Record<string, unknown>): Record<string, unknown> {
125
+ const out = Object.fromEntries(Object.entries(config).filter(([key, value]) => value !== null || !(key in defaultHookPolicy)));
126
+ const worktrees = out.worktrees;
127
+ if (typeof worktrees === "object" && worktrees !== null && !Array.isArray(worktrees)) out.worktrees = Object.fromEntries(Object.entries(worktrees).filter(([, value]) => value !== null));
128
+ return out;
129
+ }
130
+
131
+ /** The hook policy fields whose value has the wrong type. The comparison below would otherwise crash on them (a string
132
+ * where a list is expected) or compare them as the defaults. An empty or null document is the default policy, and fine. */
133
+ export function policyShapeErrors(config: Record<string, unknown>): string[] {
134
+ const out: string[] = [];
135
+ for (const key of ["max_staged_files", "max_staged_changed_lines", "max_uncommitted_files", "max_commits_per_push", "max_registry_removed_lines"])
136
+ if (config[key] !== undefined && !(typeof config[key] === "number" && Number.isFinite(config[key]))) out.push(`${key} must be a finite number`);
137
+ for (const key of ["require_plan_reference", "require_traceability"]) if (config[key] !== undefined && typeof config[key] !== "boolean") out.push(`${key} must be true or false`);
138
+ for (const key of ["protected_branches", "registry_paths"]) if (config[key] !== undefined && !(Array.isArray(config[key]) && (config[key] as unknown[]).every(item => typeof item === "string"))) out.push(`${key} must be a list of strings`);
139
+ const worktrees = config.worktrees;
140
+ if (worktrees !== undefined) {
141
+ if (typeof worktrees !== "object" || worktrees === null || Array.isArray(worktrees)) out.push("worktrees must be a mapping");
142
+ else {
143
+ const layout = worktrees as Record<string, unknown>;
144
+ for (const key of ["enabled", "require_linked_worktree"]) if (layout[key] !== undefined && typeof layout[key] !== "boolean") out.push(`worktrees.${key} must be true or false`);
145
+ for (const key of ["root", "primary_directory", "primary_branch"]) if (layout[key] !== undefined && typeof layout[key] !== "string") out.push(`worktrees.${key} must be a string`);
146
+ }
147
+ }
148
+ return out;
149
+ }
150
+
121
151
  /** Names of the policy fields in `current` that are looser than in `base`. */
122
152
  export function loosenedPolicy(base: Partial<HookPolicy> & { profiles?: unknown; delivery?: unknown }, current: Partial<HookPolicy> & { profiles?: unknown; delivery?: unknown }): string[] {
123
153
  const b = { ...defaultHookPolicy, ...base, worktrees: { ...defaultHookPolicy.worktrees, ...base.worktrees } }, c = { ...defaultHookPolicy, ...current, worktrees: { ...defaultHookPolicy.worktrees, ...current.worktrees } };
@@ -174,11 +204,20 @@ async function checkPolicy(root: string, base?: string, push = process.env.GITHU
174
204
  const unreadable = (why: string) => [{ file: "atdd-bun.yaml", detail: `the baseline atdd-bun.yaml at ${against.slice(0, 7)} ${why}, so the policy cannot be compared`, restore: push
175
205
  ? `this push is compared with the tip it replaced (${against.slice(0, 7)}), whose atdd-bun.yaml is broken; once the repaired atdd-bun.yaml is on the branch, the next push is compared with a readable tip`
176
206
  : `repair the atdd-bun.yaml on the base branch (git show ${against.slice(0, 7)}:atdd-bun.yaml) and land it there, which may need a maintainer; then bring that repair into this branch (rebase onto the base, or merge it in: a pull request is compared with its merge base) and re-run the check` }];
177
- let baseline: Partial<HookPolicy>;
178
- try { baseline = await read(before.code ? null : before.out); }
207
+ let baseline: Record<string, unknown>;
208
+ try { baseline = await read(before.code ? null : before.out) as Record<string, unknown>; }
179
209
  catch (error) { return unreadable(`could not be parsed (${String(error)})`); }
180
210
  if (typeof baseline !== "object" || baseline === null || Array.isArray(baseline)) return unreadable(`is not a policy mapping (${JSON.stringify(baseline)})`);
181
- const loosened = loosenedPolicy(baseline, await read(existsSync(path) ? await readFile(path, "utf8") : null));
211
+ // A key with no value (or only commented-out children) parses to null; the hooks read it as absent, and so does this.
212
+ baseline = withoutNulls(baseline);
213
+ const baseShape = policyShapeErrors(baseline);
214
+ if (baseShape.length) return unreadable(`has wrongly typed fields (${baseShape.join("; ")})`);
215
+ const raw = await read(existsSync(path) ? await readFile(path, "utf8") : null);
216
+ const current = typeof raw === "object" && raw !== null && !Array.isArray(raw) ? withoutNulls(raw) : raw;
217
+ // A wrongly typed field in the working tree is read by the hooks as its default, silently; it is reported instead.
218
+ const shape = typeof current === "object" && current !== null && !Array.isArray(current) ? policyShapeErrors(current) : ["the document is not a policy mapping"];
219
+ if (shape.length) return [{ file: "atdd-bun.yaml", detail: `has wrongly typed fields, which the hooks would ignore or misread: ${shape.join("; ")}`, restore: "correct the field types in atdd-bun.yaml, then re-run the check" }];
220
+ const loosened = loosenedPolicy(baseline, current);
182
221
  return loosened.length ? [{ file: "atdd-bun.yaml", detail: `loosens the policy of ${against.slice(0, 7)}: ${loosened.join("; ")}`, restore: `git checkout ${against.slice(0, 7)} -- atdd-bun.yaml` }] : [];
183
222
  }
184
223
 
@@ -12,7 +12,7 @@ A **tranche** is one independently mergeable piece of the program, on its own br
12
12
 
13
13
  Its job is throughput: every worker slot busy, every tranche moving. It never implements, repairs tests, reviews, or merges a tranche.
14
14
 
15
- 1. Split the program into tranches with explicit dependencies. Activate a tranche as soon as its own dependencies have merged; do not wait for a whole wave. A tranche whose dependencies are still open may run PLAN and `plan_review` but nothing after; revalidate its plan once they merge.
15
+ 1. Split the program into tranches with explicit dependencies, and write why the program exists, its scope and how it was split in `docs/delivery/index.adoc` (a docs-profile document; where the docs profile is active, `docs/index.adoc` is needed too, each with `:doc-id:` and `:status:`). Activate a tranche as soon as its own dependencies have merged; do not wait for a whole wave. A tranche whose dependencies are still open may run PLAN and `plan_review` but nothing after; revalidate its plan once they merge.
16
16
  2. For each active tranche, create a worktree from the owning repository's workspace and start a driver in it through the multiplexer (see Multiplexer). Send the mandate, submit it, wait 4–6 s, and read the pane: a working indicator or agent output means it landed; an empty prompt or placeholder means retry before waiting on anything.
17
17
  3. Wait on the multiplexer's events, not polling loops, and on every driver at once. When a slot frees, give it to the next ready tranche or to planning ahead.
18
18
  4. Keep provider health for the whole program. When a driver reports a model unavailable, tell every driver to go straight to the next model in its lists until it recovers, so no tranche spends time rediscovering an outage.
@@ -40,11 +40,11 @@ Agents run in panes of the terminal multiplexer named in `delivery.multiplexer`
40
40
  3. Fallback: after `fallback.after_failures` failures within `fallback.within_minutes` (outage, rate limit, no auditable report), use the next model in the stage's list and record it with its `kind` (`outage`, `rate_limit`, `no_report`, `timeout`), `failures`, and the `window` from the first to the last counted failure. REQUEST CHANGES is never a failure. With the list exhausted, `when_exhausted: block` emits `BLOCKED provider-unavailable`; `wait` keeps retrying the last model.
41
41
  4. For each finding of a REQUEST CHANGES review, either have the author fix it, or write one rebuttal with evidence (a test result, a rule id, file:line). Then run a fresh review of the same stage. If that reviewer upholds a disputed finding, emit `BLOCKED disputed-finding`; never dispute it a second time.
42
42
  5. Any commit, regenerated file, conflict fix or rebase after an approval cancels it. A change to the code goes back through `code_review`, then `final_review`.
43
- 6. Append every review to `<root>/<tranche>/evidence.yaml` as it happens; never edit an earlier entry, only add each finding's `outcome` (`fixed`, `withdrawn`, or `human` with the `decision`). Keep the raw reviewer output inside the tranche's folder and name it in `report`; nothing else goes in that folder. When `final_review` approves, set `status: ready` and `approved_sha` to that SHA, commit the evidence and reports alone, push, and merge with a merge commit once CI is green. A squash or rebase merge writes a commit no reviewer saw, and CI fails it after the merge.
43
+ 6. Append every review to `<root>/<tranche>/evidence.yaml` (`delivery.root`, default `docs/delivery/tranches`) as it happens; never edit an earlier entry, only add each finding's `outcome` (`fixed`, `withdrawn`, or `human` with the `decision`). Keep the raw reviewer output inside the tranche's folder and name it in `report`; nothing else goes in that folder. When `final_review` approves, set `status: ready` and `approved_sha` to that SHA, commit the evidence and reports alone, push, and merge with a merge commit once CI is green. A squash or rebase merge writes a commit no reviewer saw, and CI fails it after the merge.
44
44
  7. Emit events the coordinator can wait on, one line each: `PROGRAM_EVENT <tranche> <PLAN|RED|COMMIT <sha>|WORKER_START <role> <model> <sha>|WORKER_END <role> <model> <verdict>|FALLBACK <role> <from>→<to> <reason>|PLAN_REVIEW <sha>|TEST_REVIEW <sha>|CODE_REVIEW <sha>|FINAL_REVIEW <sha>|PR_OPENED <url>|MERGED <sha>|BLOCKED <reason>|HEARTBEAT>`.
45
45
 
46
46
  ```yaml
47
- # <root>/<tranche>/evidence.yaml
47
+ # docs/delivery/tranches/<tranche>/evidence.yaml
48
48
  tranche: api
49
49
  status: open # ready once final_review approves
50
50
  base_sha: 3f2a91c
@@ -59,7 +59,7 @@ reviews:
59
59
  checked: [ACC-API-001, src/wagons/api, coder.bun.error-response-*]
60
60
  findings:
61
61
  - { id: F1, severity: high, evidence: "src/wagons/api/handler.ts:42", invariant: "coded error bodies", affects: [ui], proposed_fix: "return { code: 'API_NOT_FOUND' }" } # outcome added once a fresh code_review confirms the fix
62
- report: delivery/api/code_review-1.json
62
+ report: docs/delivery/tranches/api/code_review-1.json
63
63
  ```
64
64
 
65
65
  ## Default commands