@afokapu/atdd-bun 0.7.2 → 0.8.0

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.
Files changed (34) hide show
  1. package/README.md +45 -4
  2. package/conventions/delivery/delivery.approved-sha-resolves.convention.yaml +33 -0
  3. package/conventions/delivery/delivery.config-schema.convention.yaml +43 -0
  4. package/conventions/delivery/delivery.evidence-schema.convention.yaml +34 -0
  5. package/conventions/delivery/delivery.findings-resolved.convention.yaml +42 -0
  6. package/conventions/delivery/delivery.merge-gate.convention.yaml +42 -0
  7. package/conventions/delivery/delivery.model-allowed.convention.yaml +36 -0
  8. package/conventions/delivery/delivery.reviewer-independent.convention.yaml +34 -0
  9. package/conventions/delivery/delivery.stages-complete.convention.yaml +31 -0
  10. package/detectors/delivery_evidence/atdd.implementation.yaml +33 -0
  11. package/detectors/delivery_evidence/detect.mjs +9 -0
  12. package/detectors/delivery_evidence/fixtures/clean/atdd-bun.yaml +8 -0
  13. package/detectors/delivery_evidence/fixtures/clean/delivery/api/evidence.yaml +54 -0
  14. package/detectors/delivery_evidence/fixtures/dirty/atdd-bun.yaml +3 -0
  15. package/detectors/delivery_evidence/fixtures/dirty/delivery/api/evidence.yaml +37 -0
  16. package/detectors/delivery_evidence/fixtures/dirty/delivery/data/evidence.yaml +8 -0
  17. package/detectors/delivery_evidence/fixtures/dirty/delivery/orphan/README.md +1 -0
  18. package/detectors/delivery_evidence/fixtures/dirty/delivery/ui/evidence.yaml +5 -0
  19. package/integrity.json +33 -11
  20. package/package.json +1 -1
  21. package/planner-schemas/delivery-config.schema.json +80 -0
  22. package/planner-schemas/delivery-evidence.schema.json +84 -0
  23. package/relationships.yaml +98 -0
  24. package/src/agent.ts +16 -2
  25. package/src/ci.ts +8 -1
  26. package/src/delivery.ts +415 -0
  27. package/src/enforce.ts +2 -1
  28. package/src/index.ts +2 -0
  29. package/src/integrity.ts +27 -10
  30. package/src/setup.ts +5 -2
  31. package/templates/agents/AGENTS.block.md +4 -2
  32. package/templates/agents/delivery/SKILL.md +77 -0
  33. package/templates/agents/delivery/review.md +45 -0
  34. package/templates/github/atdd-bun.yml +6 -1
@@ -0,0 +1,415 @@
1
+ import Ajv, { type ValidateFunction } from "ajv";
2
+ import addFormats from "ajv-formats";
3
+ import { existsSync, lstatSync } from "node:fs";
4
+ import { readdir, readFile } from "node:fs/promises";
5
+ import { join, resolve } from "node:path";
6
+ import type { PlanFinding } from "./planner-kernel";
7
+ import { topologyFor } from "./topology";
8
+
9
+ /** The delivery profile: the record a tranche leaves of its reviews, checked against the policy in atdd-bun.yaml.
10
+ *
11
+ * A tranche is one independently mergeable piece of a program. Its driver appends each review to
12
+ * `<root>/<tranche>/evidence.yaml`; this validator judges that record, never the running agents. It holds on
13
+ * every run: each reviewer is an allowed model, a fallback names why the preferred one was unavailable, the
14
+ * reviewer is independent of the authors, and every finding is fixed, withdrawn after a written dispute, or ruled
15
+ * on by a human. A `ready` record must also have every configured stage approved and an approved SHA that exists.
16
+ * At the merge gate (a pull request or merge queue in CI), every record the branch changes must be ready and the
17
+ * branch head may differ from its approved SHA only under the delivery root.
18
+ *
19
+ * The capability is inert until adopted in atdd-bun.yaml: a `delivery:` block, or `delivery` named in `profiles:`. */
20
+
21
+ export const STAGES = ["plan_review", "test_review", "code_review", "final_review"] as const;
22
+ export type Stage = (typeof STAGES)[number];
23
+ export type Independence = "fresh-process" | "different-model";
24
+ export type StagePolicy = { authors: string[]; reviewers: string[]; independence: Independence };
25
+ export type DeliveryPolicy = {
26
+ root: string;
27
+ independence: Independence;
28
+ stages: Partial<Record<Stage, StagePolicy>>;
29
+ fallback: { after_failures: number; within_minutes: number; when_exhausted: "block" | "wait" };
30
+ commands: Record<string, { author?: string; review?: string }>;
31
+ require_record: boolean;
32
+ multiplexer: string;
33
+ };
34
+
35
+
36
+ const DEFAULT_STAGES: Record<Stage, Omit<StagePolicy, "independence">> = {
37
+ plan_review: { authors: ["codex"], reviewers: ["glm", "claude"] },
38
+ test_review: { authors: ["glm", "claude"], reviewers: ["codex", "claude"] },
39
+ code_review: { authors: ["glm", "claude"], reviewers: ["glm", "claude"] },
40
+ final_review: { authors: ["codex"], reviewers: ["codex", "claude"] },
41
+ };
42
+ const DEFAULT_FALLBACK: DeliveryPolicy["fallback"] = { after_failures: 3, within_minutes: 10, when_exhausted: "block" };
43
+ const packageRoot = resolve(import.meta.dir, "..");
44
+
45
+ const finding = (rule_id: string, file: string, evidence: string): PlanFinding => ({ rule_id, file, evidence });
46
+ const record = (value: unknown): Record<string, unknown> | null => (value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null);
47
+
48
+ /** Whether a parsed atdd-bun.yaml adopts delivery: named in `profiles:`, or, with no list (every profile active),
49
+ * a `delivery:` block is present. Adoption is a positive act; an upgraded package changes nothing until then. */
50
+ export function deliveryAdopted(config: unknown): boolean {
51
+ const data = record(config);
52
+ if (!data) return false;
53
+ return Array.isArray(data.profiles) ? data.profiles.includes("delivery") : data.delivery !== undefined;
54
+ }
55
+
56
+ /** The effective policy: package defaults under whatever the `delivery:` block sets. `stages`, when given,
57
+ * replaces the default stage set, so a stage it omits is not required. */
58
+ export function deliveryPolicy(block: unknown): DeliveryPolicy {
59
+ // Every value is type-guarded: a malformed block is the config-schema rule's to report, never a crash here (the
60
+ // validator and the integrity check both read the policy through this function). A wrong-typed value falls back
61
+ // to its default.
62
+ const raw = record(block) ?? {}, text = (value: unknown, fallback: string) => (typeof value === "string" ? value : fallback);
63
+ const models = (value: unknown, fallback: string[]) => (Array.isArray(value) && value.every(item => typeof item === "string") ? value as string[] : fallback);
64
+ const mode = (value: unknown, fallback: Independence): Independence => (value === "fresh-process" || value === "different-model" ? value : fallback);
65
+ const count = (value: unknown, fallback: number) => (Number.isInteger(value) ? value as number : fallback);
66
+ const independence = mode(raw.independence, "fresh-process"), given = record(raw.stages), stages: DeliveryPolicy["stages"] = {};
67
+ for (const stage of STAGES) {
68
+ const entry = given ? record(given[stage]) : DEFAULT_STAGES[stage];
69
+ if (!entry) continue;
70
+ stages[stage] = { authors: models(entry.authors, DEFAULT_STAGES[stage].authors), reviewers: models(entry.reviewers, DEFAULT_STAGES[stage].reviewers), independence: mode((entry as Record<string, unknown>).independence, independence) };
71
+ }
72
+ const fallback = record(raw.fallback) ?? {};
73
+ return {
74
+ root: canonicalRoot(text(raw.root, "delivery")), independence, stages,
75
+ 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
+ commands: (record(raw.commands) ?? {}) as DeliveryPolicy["commands"], require_record: raw.require_record === false ? false : true, multiplexer: text(raw.multiplexer, "herdr"),
77
+ };
78
+ }
79
+
80
+ /** 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";
82
+
83
+ /** Why `current` enforces less than `base`, for the integrity check's loosening report. Tightening is silent. */
84
+ export function loosenedDelivery(base: unknown, current: unknown): string[] {
85
+ if (!deliveryAdopted(base)) return [];
86
+ // When the base lists its profiles, the profiles rule reports every way out (a dropped profile, a removed list). When
87
+ // delivery was adopted by its block alone, it is reported here: removing the block, or a first explicit list that
88
+ // leaves delivery out (the block was itself an explicit adoption, so the first-list rule does not excuse it).
89
+ if (!deliveryAdopted(current)) return Array.isArray(record(base)?.profiles) ? [] : ["delivery is no longer adopted"];
90
+ const before = deliveryPolicy(record(base)!.delivery), after = deliveryPolicy(record(current)!.delivery), out: string[] = [];
91
+ for (const stage of STAGES) {
92
+ const b = before.stages[stage], c = after.stages[stage];
93
+ if (!b) continue;
94
+ if (!c) { out.push(`delivery.stages drops ${stage}`); continue; }
95
+ if (b.independence === "different-model" && c.independence === "fresh-process") out.push(`delivery.stages.${stage}.independence different-model → fresh-process`);
96
+ for (const role of ["reviewers", "authors"] as const) {
97
+ const added = c[role].filter(model => !b[role].includes(model));
98
+ if (added.length) out.push(`delivery.stages.${stage}.${role} adds ${added.join(", ")}`);
99
+ // The lists are preference orders: moving a model earlier, by reordering or removing one before it, makes a
100
+ // fallback model usable without the fallback.
101
+ const promoted = c[role].filter(model => b[role].includes(model) && c[role].indexOf(model) < b[role].indexOf(model));
102
+ if (promoted.length) out.push(`delivery.stages.${stage}.${role} [${b[role].join(", ")}] → [${c[role].join(", ")}] promotes ${promoted.join(", ")}`);
103
+ }
104
+ }
105
+ // Moving the root hides every earlier record from the validator and the gate.
106
+ if (before.root !== after.root) out.push(`delivery.root ${before.root} → ${after.root}`);
107
+ if (before.require_record && !after.require_record) out.push("delivery.require_record true → false");
108
+ if (before.fallback.when_exhausted === "block" && after.fallback.when_exhausted === "wait") out.push("delivery.fallback.when_exhausted block → wait");
109
+ // The commands decide how reviews run: adding or changing one, including over the skill's protected defaults, can
110
+ // weaken review isolation. Removing one returns to the default, which is not a loosening.
111
+ for (const [model, command] of Object.entries(after.commands)) for (const role of ["author", "review"] as const)
112
+ if (command?.[role] !== undefined && before.commands[model]?.[role] !== command[role]) out.push(`delivery.commands.${model}.${role} ${before.commands[model]?.[role] === undefined ? "overrides the default" : "changed"}`);
113
+ if (after.fallback.after_failures < before.fallback.after_failures) out.push(`delivery.fallback.after_failures ${before.fallback.after_failures} → ${after.fallback.after_failures}`);
114
+ if (after.fallback.within_minutes > before.fallback.within_minutes) out.push(`delivery.fallback.within_minutes ${before.fallback.within_minutes} → ${after.fallback.within_minutes}`);
115
+ return out;
116
+ }
117
+
118
+ async function readConfig(root: string): Promise<{ data: Record<string, unknown> | null; error?: string }> {
119
+ const file = join(root, "atdd-bun.yaml");
120
+ if (!existsSync(file)) return { data: null };
121
+ try { return { data: record(Bun.YAML.parse(await readFile(file, "utf8"))) }; } catch (error) { return { data: null, error: String(error) }; }
122
+ }
123
+
124
+ async function schema(name: string): Promise<ValidateFunction> {
125
+ const ajv = new Ajv({ allErrors: true, strict: false });
126
+ addFormats(ajv);
127
+ return ajv.compile(JSON.parse(await readFile(join(packageRoot, "planner-schemas", name), "utf8")));
128
+ }
129
+
130
+ const git = async (cwd: string, args: string[]) => {
131
+ try {
132
+ const child = Bun.spawn({ cmd: ["git", ...args], cwd, stdout: "pipe", stderr: "pipe" });
133
+ return { code: await child.exited, out: (await new Response(child.stdout).text()).trim() };
134
+ } catch { return { code: 1, out: "" }; }
135
+ };
136
+
137
+ type Actor = { model: string; run: string };
138
+ type Review = { stage: Stage; sha: string; author?: Actor; reviewer: Actor; fallback?: Array<{ role?: "author" | "reviewer"; from: string; kind: string; failures: number; window: { from: string; to: string }; reason: string }>; verdict: "approve" | "request_changes"; findings?: Finding[]; report?: string };
139
+ type Finding = { id: string; severity: string; rebuttal?: string; outcome?: "fixed" | "withdrawn" | "human"; decision?: string };
140
+ type Evidence = { tranche: string; status: "open" | "ready"; base_sha: string; approved_sha?: string; reviews: Review[] };
141
+ export type EvidenceFile = { file: string; tranche: string; data: Evidence | null; error?: string };
142
+
143
+ /** Every tranche folder under the delivery root with its parsed evidence, or why it has none. */
144
+ export async function loadEvidence(root: string, policy: DeliveryPolicy): Promise<EvidenceFile[]> {
145
+ const dir = join(root, policy.root);
146
+ if (!existsSync(dir)) return [];
147
+ const out: EvidenceFile[] = [];
148
+ for (const entry of (await readdir(dir, { withFileTypes: true })).filter(entry => entry.isDirectory() || entry.isSymbolicLink()).sort((a, b) => a.name.localeCompare(b.name))) {
149
+ // A symlinked tranche folder could point anywhere, and would otherwise be skipped unread.
150
+ if (entry.isSymbolicLink()) { out.push({ file: `${policy.root}/${entry.name}`, tranche: entry.name, data: null, error: `${policy.root}/${entry.name} is a symlink; a tranche folder is a real folder holding its own evidence.yaml` }); continue; }
151
+ const file = `${policy.root}/${entry.name}/evidence.yaml`, path = join(root, file);
152
+ if (!existsSync(path)) { out.push({ file, tranche: entry.name, data: null, error: `tranche folder ${policy.root}/${entry.name} has no evidence.yaml` }); continue; }
153
+ try { out.push({ file, tranche: entry.name, data: Bun.YAML.parse(await readFile(path, "utf8")) as Evidence }); }
154
+ catch (error) { out.push({ file, tranche: entry.name, data: null, error: `could not parse ${file}: ${String(error)}` }); }
155
+ }
156
+ return out;
157
+ }
158
+
159
+ /** Models must come from the stage's list; a model after the first needs a recorded fallback from each one before it. */
160
+ function checkModels(file: string, review: Review, policy: StagePolicy, at: string, fallback: DeliveryPolicy["fallback"]): PlanFinding[] {
161
+ const out: PlanFinding[] = [];
162
+ // The count and window are the driver's claims, but explicit ones: a fallback outside the policy is out of policy.
163
+ for (const entry of review.fallback ?? []) {
164
+ if (entry.failures < fallback.after_failures) out.push(finding("delivery.model-allowed", file, `${at}: fallback from '${entry.from}' after ${entry.failures} failure(s); the policy requires ${fallback.after_failures} (delivery.fallback.after_failures)`));
165
+ const span = (Date.parse(entry.window.to) - Date.parse(entry.window.from)) / 60_000;
166
+ if (!(span >= 0)) out.push(finding("delivery.model-allowed", file, `${at}: fallback from '${entry.from}' has a window that ends before it starts`));
167
+ else if (span > fallback.within_minutes) out.push(finding("delivery.model-allowed", file, `${at}: fallback from '${entry.from}' counts failures over ${Math.round(span)} minutes; the policy allows ${fallback.within_minutes} (delivery.fallback.within_minutes)`));
168
+ }
169
+ const role = (name: "author" | "reviewer", actor: Actor | undefined, list: string[]) => {
170
+ if (!actor) return;
171
+ const index = list.indexOf(actor.model);
172
+ if (index < 0) { out.push(finding("delivery.model-allowed", file, `${at}: ${name} model '${actor.model}' is not in ${review.stage}.${name}s [${list.join(", ")}]`)); return; }
173
+ const recorded = (review.fallback ?? []).filter(entry => (entry.role ?? "reviewer") === name).map(entry => entry.from);
174
+ for (const skipped of list.slice(0, index)) if (!recorded.includes(skipped)) out.push(finding("delivery.model-allowed", file, `${at}: ${name} '${actor.model}' is a fallback, but no fallback from '${skipped}' records why it was unavailable`));
175
+ for (const from of recorded) if (!list.slice(0, index).includes(from)) out.push(finding("delivery.model-allowed", file, `${at}: ${name} fallback from '${from}' does not precede '${actor.model}' in [${list.join(", ")}]`));
176
+ };
177
+ role("author", review.author, policy.authors);
178
+ role("reviewer", review.reviewer, policy.reviewers);
179
+ return out;
180
+ }
181
+
182
+ /** Reports are data, never code: a report path is exempt from drift, so it must not be able to name a source file. */
183
+ const REPORT_EXTENSION = /\.(json|jsonl|yaml|yml|txt|md|log)$/;
184
+ const regularFile = (path: string) => { try { return lstatSync(path).isFile(); } catch { return false; } };
185
+
186
+ /** Two spellings of one commit: an abbreviated SHA is a prefix of the full one. */
187
+ const sameCommit = (a: string, b: string) => a.length <= b.length ? b.startsWith(a) : a.startsWith(b);
188
+
189
+ /** The judgement over one well-formed record. */
190
+ function checkEvidence(file: string, evidence: Evidence, policy: DeliveryPolicy): PlanFinding[] {
191
+ const out: PlanFinding[] = [], reviews = evidence.reviews;
192
+ const authorRuns = new Set(reviews.flatMap(review => review.author ? [review.author.run] : [])), reviewerRuns = new Map<string, number>();
193
+ reviews.forEach((review, index) => {
194
+ const at = `reviews[${index}] (${review.stage} @ ${review.sha})`, stage = policy.stages[review.stage];
195
+ if (!stage) { out.push(finding("delivery.evidence-schema", file, `${at}: ${review.stage} is not a configured stage in delivery.stages`)); return; }
196
+ if (!review.author) out.push(finding("delivery.evidence-schema", file, `${at}: names no author; the reviewer's independence cannot be judged without one`));
197
+ out.push(...checkModels(file, review, stage, at, policy.fallback));
198
+ if (authorRuns.has(review.reviewer.run)) out.push(finding("delivery.reviewer-independent", file, `${at}: reviewer run '${review.reviewer.run}' also authored in this tranche; a reviewer that edits becomes an author`));
199
+ if (reviewerRuns.has(review.reviewer.run)) out.push(finding("delivery.reviewer-independent", file, `${at}: reviewer run '${review.reviewer.run}' already reviewed reviews[${reviewerRuns.get(review.reviewer.run)}]; every review is a fresh process`));
200
+ else reviewerRuns.set(review.reviewer.run, index);
201
+ if (stage.independence === "different-model" && review.author && review.author.model === review.reviewer.model) out.push(finding("delivery.reviewer-independent", file, `${at}: ${review.stage} requires a different model, but '${review.reviewer.model}' reviewed its own model's work`));
202
+ });
203
+
204
+ // Findings: each one on a request-changes review is fixed, withdrawn after one written dispute, or ruled on by a
205
+ // human, once the stage has moved on (a later review of it exists) or the record is ready.
206
+ reviews.forEach((review, index) => {
207
+ if (review.verdict === "approve") {
208
+ for (const item of review.findings ?? []) if (item.severity === "critical" || item.severity === "high") out.push(finding("delivery.findings-resolved", file, `reviews[${index}] (${review.stage}) approves with ${item.severity} finding ${item.id}; a critical or high finding requests changes`));
209
+ return;
210
+ }
211
+ const later = reviews.slice(index + 1).filter(next => next.stage === review.stage);
212
+ for (const item of review.findings ?? []) {
213
+ const at = `reviews[${index}] (${review.stage}) finding ${item.id}`, upheld = later.some(next => (next.findings ?? []).some(other => other.id === item.id));
214
+ if (!item.outcome) { if (later.length || evidence.status === "ready") out.push(finding("delivery.findings-resolved", file, `${at} has no outcome; record fixed, withdrawn (after a rebuttal) or human (with the decision)`)); continue; }
215
+ if (item.outcome !== "human" && !later.length) out.push(finding("delivery.findings-resolved", file, `${at} is ${item.outcome}, but no later ${review.stage} confirms it; a fresh reviewer re-reviews every repair and every dispute`));
216
+ if (item.outcome === "withdrawn" && !item.rebuttal) out.push(finding("delivery.findings-resolved", file, `${at} is withdrawn without a rebuttal; only a written dispute with evidence can withdraw a finding`));
217
+ if (item.outcome === "withdrawn" && upheld) out.push(finding("delivery.findings-resolved", file, `${at} is withdrawn, but a later ${review.stage} raises it again; an upheld dispute goes to a human`));
218
+ if (item.outcome === "human" && !item.decision) out.push(finding("delivery.findings-resolved", file, `${at} is resolved by a human but records no decision`));
219
+ }
220
+ });
221
+ const disputes = new Map<string, { rounds: number; human: boolean; stage: Stage }>();
222
+ for (const review of reviews) for (const item of review.findings ?? []) {
223
+ const key = `${review.stage}/${item.id}`, entry = disputes.get(key) ?? { rounds: 0, human: false, stage: review.stage };
224
+ entry.rounds += item.rebuttal ? 1 : 0; entry.human ||= item.outcome === "human"; disputes.set(key, entry);
225
+ }
226
+ for (const [key, entry] of disputes) if (entry.rounds > 1 && !entry.human) out.push(finding("delivery.findings-resolved", file, `finding ${key} was disputed ${entry.rounds} times; after one round a disputed finding goes to a human (outcome: human, with the decision)`));
227
+
228
+ if (evidence.status === "ready") {
229
+ const configured = STAGES.filter(stage => policy.stages[stage]);
230
+ for (const stage of configured) {
231
+ const last = reviews.filter(review => review.stage === stage).at(-1);
232
+ if (!last) out.push(finding("delivery.stages-complete", file, `status is ready, but ${stage} has no review`));
233
+ else if (last.verdict !== "approve") out.push(finding("delivery.stages-complete", file, `status is ready, but the last ${stage} requests changes`));
234
+ }
235
+ const closing = configured.at(-1), approval = closing && reviews.filter(review => review.stage === closing).at(-1);
236
+ if (approval && approval.verdict === "approve" && !sameCommit(approval.sha, evidence.approved_sha ?? "")) out.push(finding("delivery.stages-complete", file, `approved_sha ${evidence.approved_sha} is not the SHA the last ${closing} approved (${approval.sha})`));
237
+ }
238
+ return out;
239
+ }
240
+
241
+ export type GateMode = "merge" | "post-merge";
242
+ /** The gate: ATDD_DELIVERY_GATE, which the generated CI sets to `merge` on pull requests and the merge queue and to
243
+ * `post-merge` on pushes to the base branch. Explicit rather than inferred from the CI event, so a repository's own
244
+ * tests are never judged as tranches. */
245
+ export function gateMode(env: Record<string, string | undefined> = process.env): GateMode | null {
246
+ return env.ATDD_DELIVERY_GATE === "merge" || env.ATDD_DELIVERY_GATE === "post-merge" ? env.ATDD_DELIVERY_GATE : null;
247
+ }
248
+
249
+ /** The commits the gate compares: a merge checkout (pull request, merge queue) is judged as base = first parent,
250
+ * head = second parent; otherwise HEAD against the configured base ref. */
251
+ async function gateRange(root: string, mode: GateMode, base?: string): Promise<{ base: string; head: string } | null> {
252
+ // After the merge, the pushed commit is judged against its first parent: what this push brought in.
253
+ // With the push's before-SHA (the generated CI passes it as ATDD_BASE_REF), everything the push brought in is judged,
254
+ // not only its last commit; a new branch's first push (all zeros) falls back to the first parent.
255
+ if (mode === "post-merge") {
256
+ const head = (await git(root, ["rev-parse", "HEAD"])).out, before = base ?? process.env.ATDD_BASE_REF;
257
+ if (before && !/^0+$/.test(before)) {
258
+ // An explicit baseline that cannot be resolved fails closed, as the integrity check does, rather than narrowing.
259
+ const resolved = await git(root, ["rev-parse", "--verify", "--quiet", `${before}^{commit}`]);
260
+ return resolved.code || !resolved.out ? null : { base: resolved.out, head };
261
+ }
262
+ const parent = await git(root, ["rev-parse", "--verify", "--quiet", "HEAD^1"]);
263
+ return parent.code || !parent.out ? null : { base: parent.out, head };
264
+ }
265
+ // An explicit base wins: a branch that merged its base in locally is still judged against that base.
266
+ const explicit = base ?? process.env.ATDD_BASE_REF;
267
+ if (explicit && !/^0+$/.test(explicit)) return (await git(root, ["rev-parse", "--verify", "--quiet", `${explicit}^{commit}`])).code ? null : againstRef(root, explicit);
268
+ // CI checks a pull request out as a merge commit: first parent the base, second the tranche head.
269
+ const merge = await git(root, ["rev-parse", "--verify", "--quiet", "HEAD^2"]);
270
+ if (!merge.code && merge.out) return { base: (await git(root, ["rev-parse", "HEAD^1"])).out, head: merge.out };
271
+ const ref = process.env.GITHUB_BASE_REF ? `origin/${process.env.GITHUB_BASE_REF}` : "origin/HEAD";
272
+ return (await git(root, ["rev-parse", "--verify", "--quiet", ref])).code ? null : againstRef(root, ref);
273
+ }
274
+
275
+ async function againstRef(root: string, ref: string): Promise<{ base: string; head: string } | null> {
276
+ const since = (await git(root, ["merge-base", "HEAD", ref])).out, head = (await git(root, ["rev-parse", "HEAD"])).out;
277
+ return since && head ? { base: since, head } : null;
278
+ }
279
+
280
+ /** `gate: true` is the pre-merge gate; `false` disables it; unset reads ATDD_DELIVERY_GATE. */
281
+ export type DeliveryOptions = { gate?: boolean | GateMode; base?: string };
282
+
283
+ export async function validateDelivery(root = process.cwd(), options: DeliveryOptions = {}): Promise<PlanFinding[]> {
284
+ const absolute = resolve(root), config = await readConfig(absolute);
285
+ if (config.error) return [finding("delivery.config-schema", "atdd-bun.yaml", `atdd-bun.yaml could not be parsed, so the delivery policy cannot be read: ${config.error}`)];
286
+ if (!deliveryAdopted(config.data)) return [];
287
+ const findings: PlanFinding[] = [], validConfig = await schema("delivery-config.schema.json"), block = "delivery" in config.data! ? config.data!.delivery : {};
288
+ let policy = deliveryPolicy(block);
289
+ if (!validConfig(block)) {
290
+ for (const error of validConfig.errors ?? []) findings.push(finding("delivery.config-schema", "atdd-bun.yaml", `delivery${error.instancePath.replaceAll("/", ".")} ${error.message ?? error.keyword}`));
291
+ policy = deliveryPolicy({});
292
+ }
293
+ // The root is an evidence namespace only. Overlapping a plan, source, test, e2e or telemetry root would let a product
294
+ // file be labelled a report and escape review; such a root is reported, and the default is used instead.
295
+ const topology = await topologyFor(absolute), owned = [topology.planRoot, topology.sourceRoot, topology.testRoot, topology.e2eRoot, topology.telemetryRoot];
296
+ const overlap = owned.find(other => policy.root === other || policy.root.startsWith(`${other}/`) || other.startsWith(`${policy.root}/`));
297
+ if (overlap) {
298
+ 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" };
300
+ }
301
+ const validEvidence = await schema("delivery-evidence.schema.json"), files = await loadEvidence(absolute, policy);
302
+ const mode = options.gate === undefined ? gateMode() : options.gate === true ? "merge" : options.gate || null;
303
+ // At the gate, a record the change does not touch was judged when it merged. It is not judged again, against a
304
+ // later policy, schema or history: records are append-only and could never be repaired, so one tightening would
305
+ // fail every later change. Outside the gate every record is judged. The gate itself still rejects any change to
306
+ // an untouched record's folder (mergeGate).
307
+ const onBase = mode ? (await gateRange(absolute, mode, options.base))?.base ?? null : null;
308
+ for (const entry of files) {
309
+ if (onBase && existsSync(join(absolute, entry.file)) && !(await git(absolute, ["diff", "--quiet", onBase, "--", `${policy.root}/${entry.tranche}`])).code) continue;
310
+ if (!entry.data) { findings.push(finding("delivery.evidence-schema", entry.file, entry.error ?? "evidence is missing")); continue; }
311
+ if (!validEvidence(entry.data)) {
312
+ for (const error of validEvidence.errors ?? []) findings.push(finding("delivery.evidence-schema", entry.file, `${entry.file} violates delivery-evidence.schema.json: ${error.instancePath || "/"} ${error.message ?? error.keyword}`));
313
+ continue;
314
+ }
315
+ if (entry.data.tranche !== entry.tranche) findings.push(finding("delivery.evidence-schema", entry.file, `tranche '${entry.data.tranche}' does not match its folder '${entry.tranche}'`));
316
+ // A report lives in its tranche's folder: a report path is exempt from drift, so it may never name other files.
317
+ const folder = `${policy.root}/${entry.tranche}/`;
318
+ const seen = new Map<string, number>();
319
+ for (const [index, review] of entry.data.reviews.entries()) {
320
+ if (review.report === undefined) continue;
321
+ if (!(review.report.startsWith(folder) && review.report !== entry.file && review.report.split("/").every(part => part && part !== "." && part !== "..") && REPORT_EXTENSION.test(review.report)))
322
+ findings.push(finding("delivery.evidence-schema", entry.file, `reviews[${index}] report ${review.report} must be a data file (${REPORT_EXTENSION.source.slice(3, -2).replaceAll("|", ", ")}) inside ${folder}, other than evidence.yaml`));
323
+ // Each review retains its own raw output: one file cannot stand for several reviews.
324
+ if (seen.has(review.report)) findings.push(finding("delivery.evidence-schema", entry.file, `reviews[${index}] reuses report ${review.report} from reviews[${seen.get(review.report)}]; every review retains its own report`));
325
+ else seen.set(review.report, index);
326
+ }
327
+ findings.push(...checkEvidence(entry.file, entry.data, policy));
328
+ if (entry.data.status === "ready") {
329
+ // Ready means auditable: every review's raw output is retained and named.
330
+ for (const [index, review] of entry.data.reviews.entries()) if (!review.report || !regularFile(join(absolute, review.report))) findings.push(finding("delivery.stages-complete", entry.file, `status is ready, but reviews[${index}] (${review.stage}) ${review.report ? `names report ${review.report}, which is not a regular file` : "retains no report"}`));
331
+ if ((await git(absolute, ["cat-file", "-e", `${entry.data.approved_sha}^{commit}`])).code) { findings.push(finding("delivery.approved-sha-resolves", entry.file, `approved_sha ${entry.data.approved_sha} is not a commit in this repository's history`)); continue; }
332
+ // Every stage's approval names a real commit in the approved history, in lifecycle order.
333
+ let previous: { stage: Stage; sha: string } | null = null;
334
+ for (const stage of STAGES.filter(name => policy.stages[name])) {
335
+ const last = entry.data.reviews.filter(review => review.stage === stage).at(-1);
336
+ if (!last || last.verdict !== "approve") continue; // reported by delivery.stages-complete
337
+ if ((await git(absolute, ["cat-file", "-e", `${last.sha}^{commit}`])).code) { findings.push(finding("delivery.approved-sha-resolves", entry.file, `${stage} approved ${last.sha}, which is not a commit in this repository's history`)); continue; }
338
+ if ((await git(absolute, ["merge-base", "--is-ancestor", last.sha, entry.data.approved_sha!])).code) { findings.push(finding("delivery.approved-sha-resolves", entry.file, `${stage} approved ${last.sha.slice(0, 7)}, which is not in the history of approved_sha ${entry.data.approved_sha!.slice(0, 7)}`)); continue; }
339
+ if (previous && (await git(absolute, ["merge-base", "--is-ancestor", previous.sha, last.sha])).code) findings.push(finding("delivery.approved-sha-resolves", entry.file, `${stage} approved ${last.sha.slice(0, 7)}, which does not contain what ${previous.stage} approved (${previous.sha.slice(0, 7)}); stages follow the lifecycle`));
340
+ previous = { stage, sha: last.sha };
341
+ }
342
+ // The closing review does not replace the stage before it: code that changed after that stage approved must go
343
+ // back through it (driver step 5), so nothing outside the delivery root may differ between the two.
344
+ // Anchored on code_review, the stage that approves code: not the position, which would ask plan_review to approve
345
+ // the final code under a reduced stage set.
346
+ const configured = STAGES.filter(name => policy.stages[name]), closing = configured.at(-1);
347
+ const before: Stage | undefined = configured.includes("code_review") && closing !== "code_review" ? "code_review" : undefined;
348
+ const earlier = before && entry.data.reviews.filter(review => review.stage === before).at(-1);
349
+ if (earlier && earlier.verdict === "approve" && closing && !(await git(absolute, ["cat-file", "-e", `${earlier.sha}^{commit}`])).code) {
350
+ const changedSince = (await git(absolute, ["diff", "--no-renames", "--name-only", earlier.sha, entry.data.approved_sha!, "--", ":(top)", `:(exclude)${policy.root}`])).out.split("\n").filter(Boolean);
351
+ if (changedSince.length) findings.push(finding("delivery.stages-complete", entry.file, `${changedSince.slice(0, 5).join(", ")}${changedSince.length > 5 ? ` and ${changedSince.length - 5} more` : ""} changed after ${before} approved ${earlier.sha.slice(0, 7)}, and only ${closing} reviewed the change; a code change goes back through ${before}`));
352
+ }
353
+ }
354
+ }
355
+ if (mode) findings.push(...await mergeGate(absolute, policy, files, mode, options.base));
356
+ return findings;
357
+ }
358
+
359
+ /** Every record the branch changes must be ready and approve a commit the head contains, the head may differ from
360
+ * that commit only under the delivery root, and (require_record) a change outside the root needs a record at all.
361
+ * Post-merge, the pushed commit must still contain the approved one: a squash or rebase merge rewrites it. */
362
+ async function mergeGate(root: string, policy: DeliveryPolicy, files: EvidenceFile[], mode: GateMode, base?: string): Promise<PlanFinding[]> {
363
+ const range = await gateRange(root, mode, base);
364
+ if (!range) return [finding("delivery.merge-gate", "atdd-bun.yaml", "the merge gate cannot resolve the base branch; fetch full history (fetch-depth: 0) or set ATDD_BASE_REF")];
365
+ const span = mode === "merge" ? [`${range.base}...${range.head}`] : [range.base, range.head];
366
+ const changedHere = (await git(root, ["diff", "--no-renames", "--relative", "--name-only", ...span])).out.split("\n").filter(Boolean);
367
+ // A record is exactly <root>/<tranche>/evidence.yaml and was loaded: an evidence.yaml at any other depth, or a
368
+ // deleted one (reported below), never counts as the record that covers a change.
369
+ const isRecord = (path: string) => path.split("/").length === policy.root.split("/").length + 2 && path.startsWith(`${policy.root}/`) && path.endsWith("/evidence.yaml");
370
+ const changed = changedHere.filter(path => isRecord(path) && files.some(file => file.file === path && file.data)), outside = changedHere.filter(path => !path.startsWith(`${policy.root}/`));
371
+ // Drift is judged over the whole repository, not just this root: a change anywhere after approval counts.
372
+ const prefix = (await git(root, ["rev-parse", "--show-prefix"])).out, out: PlanFinding[] = [];
373
+ const inRange = new Set((await git(root, ["diff", "--no-renames", "--name-only", ...span])).out.split("\n").filter(Boolean));
374
+ const deleted = (await git(root, ["diff", "--no-renames", "--relative", "--name-only", "--diff-filter=D", ...span, "--", policy.root])).out.split("\n").filter(path => path.endsWith("/evidence.yaml"));
375
+ for (const path of deleted) out.push(finding("delivery.merge-gate", path, `${mode === "merge" ? "the branch" : "this push"} deletes ${path}; records are append-only, and deleting one would escape its findings and the gate`));
376
+ // A record or report already on the base branch is final: editing an old record would make its reports "named by a
377
+ // changed record" and so exempt from drift. A later change to a tranche is a new tranche with its own record.
378
+ const final = (await git(root, ["diff", "--no-renames", "--relative", "--name-only", "--diff-filter=MRTC", ...span, "--", policy.root])).out.split("\n").filter(Boolean);
379
+ 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
+ // 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] : []) ?? []));
382
+ for (const path of changedHere.filter(path => path.startsWith(`${policy.root}/`) && !isRecord(path) && !named.has(path)))
383
+ 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
+ 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)`));
385
+ // Every changed ready record, its reports, and the approved SHAs that may cover each other's files.
386
+ const readyChanged = changed.map(path => files.find(file => file.file === path)!).filter(entry => entry.data?.status === "ready");
387
+ const siblings = readyChanged.map(entry => entry.data!.approved_sha!).filter(Boolean);
388
+ const allEvidence = new Set(readyChanged.flatMap(entry => [entry.file, ...entry.data!.reviews.flatMap(review => review.report ? [review.report] : [])]).map(file => `${prefix}${file}`));
389
+ for (const path of changed) {
390
+ const entry = files.find(file => file.file === path);
391
+ if (!entry?.data) continue; // deleted (reported above), or already reported by the schema rule
392
+ if (entry.data.status !== "ready") { out.push(finding("delivery.merge-gate", path, `${path} is ${entry.data.status}; a tranche merges only when its record is ready`)); continue; }
393
+ const sha = entry.data.approved_sha!;
394
+ if ((await git(root, ["cat-file", "-e", `${sha}^{commit}`])).code) continue; // reported by delivery.approved-sha-resolves
395
+ if ((await git(root, ["merge-base", "--is-ancestor", sha, range.head])).code) { out.push(finding("delivery.merge-gate", path, `approved_sha ${sha.slice(0, 7)} is not contained in ${mode === "merge" ? "the branch head" : "the merged commit"}${mode === "post-merge" ? "; a squash or rebase merge rewrites the approved commit, so merge with a merge commit" : "; the branch was rewritten after approval"}`)); continue; }
396
+ // Only the record and the reports it names may postdate the approval; anything else under the root is drift too.
397
+ const exempt = new Set([path, ...entry.data.reviews.flatMap(review => review.report ? [review.report] : [])].map(file => `${prefix}${file}`));
398
+ // Drift: what this change brings in that differs from the approved commit. After a merge commit, the tranche's
399
+ // side is the second parent; a direct push is judged at its head. Intersecting with the change's own files keeps
400
+ // other tranches merged since the approval out of it.
401
+ const merged = mode === "post-merge" ? await git(root, ["rev-parse", "--verify", "--quiet", `${range.head}^2`]) : { code: 1, out: "" };
402
+ const tip = !merged.code && merged.out && !(await git(root, ["merge-base", "--is-ancestor", sha, merged.out])).code ? merged.out : range.head;
403
+ const candidates = (await git(root, ["diff", "--no-renames", "--name-only", sha, tip])).out.split("\n").filter(Boolean).filter(file => inRange.has(file) && !exempt.has(file) && !allEvidence.has(file));
404
+ // Several tranches may land in one change (a merge-queue batch): a file another changed ready record approved
405
+ // with exactly this content is that tranche's, not drift.
406
+ const drift: string[] = [];
407
+ for (const file of candidates) {
408
+ let covered = false;
409
+ for (const other of siblings) if (other !== sha && !(await git(root, ["diff", "--quiet", other, tip, "--", `:(top)${file}`])).code) { covered = true; break; }
410
+ if (!covered) drift.push(file);
411
+ }
412
+ if (drift.length) out.push(finding("delivery.merge-gate", path, `${mode === "merge" ? "the branch head" : "this push"} changes ${drift.slice(0, 5).join(", ")}${drift.length > 5 ? ` and ${drift.length - 5} more` : ""} after approved_sha ${sha.slice(0, 7)}; any change after approval needs a fresh review`));
413
+ }
414
+ return out;
415
+ }
package/src/enforce.ts CHANGED
@@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
4
4
  import { join, resolve } from "node:path";
5
5
  import { topologyFor } from "./topology";
6
6
 
7
- export type Profile = "traceability" | "topology" | "docs" | "planner" | "telemetry" | "coder" | "tester" | "security" | "architecture" | "metrics" | "runtime" | "interlocking" | "htmx" | "design" | "all";
7
+ export type Profile = "traceability" | "topology" | "docs" | "planner" | "telemetry" | "delivery" | "coder" | "tester" | "security" | "architecture" | "metrics" | "runtime" | "interlocking" | "htmx" | "design" | "all";
8
8
 
9
9
  export type Violation = {
10
10
  rule_id: string;
@@ -28,6 +28,7 @@ const profiles: Record<Exclude<Profile, "all">, string[]> = {
28
28
  docs: ["planner_docs_capability"],
29
29
  planner: ["planner_plan_integrity", "planner_schema_validation", "planner_static_validators", "atdd_topology"],
30
30
  telemetry: ["planner_telemetry_plan", "bun_telemetry_code", "bun_telemetry_test"],
31
+ delivery: ["delivery_evidence"],
31
32
  coder: ["bun_green_traceability_detector", "bun_clean_architecture_detector", "bun_ts_metrics_detector", "bun_fullstack_detector", "bun_design_system_detector", "bun_responsive_detector", "atdd_topology"],
32
33
  tester: ["bun_tester_discipline_detector", "htmx_e2e_detector", "atdd_topology"],
33
34
  security: ["bun_security_hygiene_detector"],
package/src/index.ts CHANGED
@@ -7,6 +7,8 @@ export type { PlanArtifact, PlanFinding, PlanGraph, PlanKind } from "./planner-k
7
7
  export { validateStaticPlannerConventions } from "./planner-validators";
8
8
  export { validateTelemetryPlan, loadTelemetryFiles, telemetryDecisionOf, CONCRETE_URN } from "./telemetry-plan";
9
9
  export type { TelemetryPlanItem, TelemetryFile } from "./telemetry-plan";
10
+ export { canonicalRoot, gateMode, deliveryAdopted, deliveryPolicy, loadEvidence, loosenedDelivery, STAGES, validateDelivery } from "./delivery";
11
+ export type { DeliveryOptions, GateMode, DeliveryPolicy, EvidenceFile, Independence, Stage, StagePolicy } from "./delivery";
10
12
  export { PLANNER_SCHEMA_RULE_ID, validatePlannerSchemas } from "./planner-schema-validator";
11
13
  export { defaultHookPolicy, hookEvents, hooksStatus, installHooks, runHook, uninstallHooks } from "./hooks";
12
14
  export type { HookEvent, HookPolicy } from "./hooks";
package/src/integrity.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
3
3
  import { dirname, join, relative, resolve, sep } from "node:path";
4
- import { instructionPaths } from "./agent";
4
+ import { deliveryInstalled, deliverySkillFiles, instructionPaths } from "./agent";
5
+ import { renderWorkflow } from "./ci";
6
+ import { loosenedDelivery } from "./delivery";
5
7
  import { defaultHookPolicy, type HookPolicy } from "./hooks";
6
8
 
7
9
  /**
@@ -86,15 +88,22 @@ async function checkDependency(root: string): Promise<IntegrityFinding[]> {
86
88
  }
87
89
 
88
90
  /** Generated files are byte-identical to what the installed version generates (ignoring its version stamp). */
89
- async function checkGenerated(root: string, packageRoot: string): Promise<IntegrityFinding[]> {
91
+ async function checkGenerated(root: string, packageRoot: string, skipWorkflow = false): Promise<IntegrityFinding[]> {
90
92
  const findings: IntegrityFinding[] = [];
91
93
  const same = async (file: string, template: string, restore: string) => {
92
94
  const path = join(root, file);
93
95
  if (!existsSync(path)) return findings.push({ file, detail: "is missing", restore });
94
96
  if (unstamp(await readFile(path, "utf8")) !== unstamp(await readFile(join(packageRoot, template), "utf8"))) findings.push({ file, detail: "was edited; it must match what the package generates", restore });
95
97
  };
96
- await same(WORKFLOW, "templates/github/atdd-bun.yml", "bun run atdd-bun ci init --replace");
98
+ // The workflow is rendered per repository (its protected branches), so it is compared with that rendering.
99
+ const workflow = join(root, WORKFLOW);
100
+ if (skipWorkflow) { /* rendered from atdd-bun.yaml, which does not parse: reported by the caller */ }
101
+ else if (!existsSync(workflow)) findings.push({ file: WORKFLOW, detail: "is missing", restore: "bun run atdd-bun ci init --replace" });
102
+ else if (unstamp(await readFile(workflow, "utf8")) !== unstamp(await renderWorkflow(root))) findings.push({ file: WORKFLOW, detail: "was edited; it must match what the package generates (protected_branches decides its push branches)", restore: "bun run atdd-bun ci init --replace" });
97
103
  for (const skill of SKILLS) await same(skill, "templates/agents/atdd/SKILL.md", "bun run atdd-bun agent init --replace");
104
+ // Required while delivery is adopted; protected whenever present, so turning delivery off and on cannot launder an edit.
105
+ const adopted = await deliveryInstalled(root);
106
+ for (const [path, template] of deliverySkillFiles) if (adopted || existsSync(join(root, path))) await same(path, `templates/agents/${template}`, "bun run atdd-bun agent init --replace");
98
107
  await same(relative(root, await testFilePath(root)), "templates/agents/atdd-bun.integrity.test.ts", "bun run atdd-bun integrity init --replace");
99
108
  const canonical = (await readFile(join(packageRoot, "templates/agents/AGENTS.block.md"), "utf8")).match(BLOCK)![0];
100
109
  for (const file of instructionPaths) {
@@ -110,7 +119,7 @@ async function checkGenerated(root: string, packageRoot: string): Promise<Integr
110
119
  const explicitProfiles = (config: { profiles?: unknown }): string[] | null => Array.isArray(config.profiles) ? config.profiles.map(String) : null;
111
120
 
112
121
  /** Names of the policy fields in `current` that are looser than in `base`. */
113
- export function loosenedPolicy(base: Partial<HookPolicy> & { profiles?: unknown }, current: Partial<HookPolicy> & { profiles?: unknown }): string[] {
122
+ export function loosenedPolicy(base: Partial<HookPolicy> & { profiles?: unknown; delivery?: unknown }, current: Partial<HookPolicy> & { profiles?: unknown; delivery?: unknown }): string[] {
114
123
  const b = { ...defaultHookPolicy, ...base, worktrees: { ...defaultHookPolicy.worktrees, ...base.worktrees } }, c = { ...defaultHookPolicy, ...current, worktrees: { ...defaultHookPolicy.worktrees, ...current.worktrees } };
115
124
  const out: string[] = [];
116
125
  for (const key of ["max_staged_files", "max_staged_changed_lines", "max_uncommitted_files", "max_commits_per_push", "max_registry_removed_lines"] as const) if (Number(c[key]) > Number(b[key])) out.push(`${key} ${b[key]} → ${c[key]}`);
@@ -126,18 +135,20 @@ export function loosenedPolicy(base: Partial<HookPolicy> & { profiles?: unknown
126
135
  if (before && !after) out.push(`profiles becomes implicit: the explicit list [${before.join(", ")}] was removed`);
127
136
  const dropped = before && after ? before.filter(name => !after.includes(name)) : [];
128
137
  if (dropped.length) out.push(`profiles drops ${dropped.join(", ")}`);
138
+ out.push(...loosenedDelivery(base, current));
129
139
  return out;
130
140
  }
131
141
 
132
- /** atdd-bun.yaml is not looser than on the branch being merged into. */
133
- /** How to recover a baseline that cannot be resolved. A replaced tip is reachable from no branch, so only a fetch by its
134
- * full object id brings it back (SHA-1 or SHA-256, any case); an abbreviated id cannot be fetched; a ref name can. */
142
+ /** How to recover a baseline that cannot be resolved. A full object id (SHA-1 or SHA-256, any case) is fetched by id: after
143
+ * a force push the replaced tip is on no branch. A shorter hex string may be an abbreviated SHA, which cannot be fetched,
144
+ * or a branch or tag that merely looks like hex; anything else is a ref name, which a plain fetch brings. */
135
145
  export function baselineRestore(ref: string): string {
136
- if (/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(ref)) return `git fetch origin ${ref}, then re-run the check (a replaced tip is reachable from no branch, so a plain fetch does not bring it)`;
137
- if (/^[0-9a-f]{4,63}$/i.test(ref)) return `set ATDD_BASE_REF to the full SHA of ${ref} (an abbreviated SHA cannot be fetched), fetch it with git fetch origin <full SHA>, then re-run the check`;
146
+ if (/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(ref)) return `git fetch origin ${ref}, then re-run the check (a plain fetch may not bring it: after a force push the replaced tip is on no branch)`;
147
+ if (/^[0-9a-f]{4,39}$/i.test(ref)) return `if ${ref} is an abbreviated SHA, set ATDD_BASE_REF to its full SHA and git fetch origin <full SHA> (an abbreviated SHA cannot be fetched); if it is a branch or tag, git fetch origin; then re-run the check`;
138
148
  return `git fetch origin, then re-run the check`;
139
149
  }
140
150
 
151
+ /** atdd-bun.yaml is not looser than on the branch being merged into. */
141
152
  async function checkPolicy(root: string, base?: string, push = process.env.GITHUB_EVENT_NAME === "push"): Promise<IntegrityFinding[]> {
142
153
  // On a push, the generated CI passes the pre-push tip (github.event.before) as ATDD_BASE_REF, so a multi-commit push
143
154
  // is judged as a whole: [docs, security] → no list → [docs] in one push cannot read as a first adoption.
@@ -163,7 +174,13 @@ async function checkPolicy(root: string, base?: string, push = process.env.GITHU
163
174
 
164
175
  export async function checkIntegrity(options: IntegrityOptions = {}): Promise<IntegrityFinding[]> {
165
176
  const root = resolve(options.root ?? process.cwd()), packageRoot = options.packageRoot ?? ownRoot;
166
- return [...await checkInstalledPackage(packageRoot), ...await checkDependency(root), ...await checkGenerated(root, packageRoot), ...await checkPolicy(root, options.base, options.push)];
177
+ // An unreadable atdd-bun.yaml is a finding, not a crash: the policy and the workflow rendered from it cannot be
178
+ // judged until it parses, and every other finding is kept.
179
+ const config = join(root, "atdd-bun.yaml");
180
+ let unreadable: IntegrityFinding | null = null;
181
+ if (existsSync(config)) try { Bun.YAML.parse(await readFile(config, "utf8")); } catch (error) { unreadable = { file: "atdd-bun.yaml", detail: `could not be parsed, so the policy and the workflow's push branches cannot be judged: ${String(error)}`, restore: "fix the YAML syntax in atdd-bun.yaml, then re-run the check" }; }
182
+ const base = [...await checkInstalledPackage(packageRoot), ...await checkDependency(root)];
183
+ return unreadable ? [...base, ...await checkGenerated(root, packageRoot, true), unreadable] : [...base, ...await checkGenerated(root, packageRoot), ...await checkPolicy(root, options.base, options.push)];
167
184
  }
168
185
 
169
186
  /** The message both the local test and CI print: addressed to the agent, with the way back for every file. */
package/src/setup.ts CHANGED
@@ -11,16 +11,20 @@ import { join } from "node:path";
11
11
  * without this a greenfield repository's first `profiles:` line could switch most of them off unreported. A
12
12
  * brownfield repository trims the list before its first commit; after that, dropping one is a reported loosening.
13
13
  * An existing atdd-bun.yaml is never touched. */
14
+ /** Profiles a repository adopts on purpose, never by default: delivery gates every change on a reviewed tranche record. */
15
+ const OPT_IN = ["delivery"];
14
16
  export async function policyInit(root = process.cwd()) {
15
17
  const file = join(root, "atdd-bun.yaml");
16
18
  if (existsSync(file)) return { ok: true, message: `${file} kept` };
17
- await writeFile(file, `# Generated by atdd-bun init. The profiles this repository enforces; trim the list before the first commit to\n# adopt gradually. Once committed, removing a profile or the list is reported by the integrity check.\nprofiles: [${concreteProfiles.join(", ")}]\n`);
19
+ await writeFile(file, `# Generated by atdd-bun init. The profiles this repository enforces; trim the list before the first commit to\n# adopt gradually. Once committed, removing a profile or the list is reported by the integrity check.\nprofiles: [${concreteProfiles.filter(profile => !OPT_IN.includes(profile)).join(", ")}]\n`);
18
20
  return { ok: true, message: file };
19
21
  }
20
22
 
21
23
  /** Install the package's opt-in local surfaces without touching unrelated
22
24
  * workflows or hook paths. Dependency installation itself never calls this. */
23
25
  export async function initializeRepository(root = process.cwd(), replace = false) {
26
+ // The policy first: the CI workflow and the agent files are rendered from it.
27
+ const policy = await policyInit(root);
24
28
  const existingHooks = await hooksStatus(root);
25
29
  const hooks = replace || !existingHooks.ok ? await installHooks(root, replace) : existingHooks;
26
30
  if (!hooks.ok) return { ok: false, message: `hooks: ${hooks.message}` };
@@ -36,6 +40,5 @@ export async function initializeRepository(root = process.cwd(), replace = false
36
40
  const existingIntegrity = await integrityStatus(root);
37
41
  const integrity = replace || !existingIntegrity.ok ? await integrityInit(root, replace) : existingIntegrity;
38
42
  if (!integrity.ok) return { ok: false, message: `integrity test: ${integrity.message}` };
39
- const policy = await policyInit(root);
40
43
  return { ok: true, message: `hooks: ${hooks.message}\nCI workflow: ${ci.message}\nagent skill: ${agent.message}\nintegrity test: ${integrity.message}\npolicy: ${policy.message}` };
41
44
  }
@@ -3,7 +3,9 @@
3
3
 
4
4
  Before changing code, tests, or `plan/`, follow `.agents/skills/atdd/SKILL.md`: PLAN → RED → GREEN → SMOKE → REFACTOR → TRACE, passing each stage's `atdd-bun` gate before starting the next.
5
5
 
6
- Capabilities can be enabled gradually: a greenfield repository runs every profile; a brownfield one lists the profiles it enforces in `atdd-bun.yaml` (`profiles:`), chosen by the operator.
6
+ `atdd-bun.yaml` controls which profiles are active (`profiles:`; absent, every profile runs) and their settings. Turn a profile on or off only when the user asks. Adding one is always allowed; removing one loosens the gate, so the integrity check reports it until a human approves it on the base branch.
7
7
 
8
- Never modify the toolkit itself (`node_modules/@afokapu/atdd-bun`, or the files atdd-bun generates); change only the configuration it offers, and never to loosen it. The integrity test and CI fail if you do.
8
+ When `delivery` is active, deliver tranches through `.agents/skills/delivery/SKILL.md`: the reviews it requires are recorded under the delivery root and checked in CI.
9
+
10
+ Never modify the toolkit itself (`node_modules/@afokapu/atdd-bun`, or the files atdd-bun generates); change only the configuration it offers, and loosen it only when the user asks. The integrity test and CI fail if the toolkit is modified.
9
11
  <!-- atdd-bun:end -->