@afokapu/atdd-bun 0.10.26 → 0.10.28

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.
@@ -13,7 +13,7 @@ content:
13
13
  summary: >-
14
14
  The skill and the validators read the same policy. A misspelled key would be ignored by one and not the other, so a malformed block is a finding, and the rest of the run judges evidence against the package defaults.
15
15
  normative_text: |
16
- The policy decides which models may write and review each stage, in preference order, how independent a reviewer must be, and when a model counts as unavailable. It is read by the delivery skill when it dispatches work and by this profile when it judges the record, so both must see the same, valid document.
16
+ The policy decides which models may write and review each stage (an allowed set, in no order; "*" allows any), how independent a reviewer must be, and when a model counts as unavailable. It is read by the delivery skill when it dispatches work and by this profile when it judges the record, so both must see the same, valid document.
17
17
  fix_hint: |
18
18
  Correct the key the finding names. The defaults are:
19
19
 
@@ -4,31 +4,19 @@ kind: rule
4
4
  status: active
5
5
  name: Authors and reviewers are allowed models, and every fallback says why
6
6
  statement: >-
7
- Each work entry's writer appears in that stage's writer list and each review's reviewer in its reviewer list; a model after the first is used only with a recorded fallback from each model before it, stating the kind of unavailability (outage, rate_limit, no_report, timeout), the failures observed (at least delivery.fallback.after_failures) within a window no longer than delivery.fallback.within_minutes, and the reason; or, where the operator ordered the substitution, kind operator with who decided, when, the ruling it is recorded in, and the reason (REQUIRED).
7
+ Each work entry's writer is a model its stage allows for writers, and each review's reviewer a model it allows for reviewers. A stage's list is the set of allowed models, in no order; "*" allows any model. Which allowed model does the work is the operator's choice at any moment, so no fallback has to explain it; the record names the model that actually ran (REQUIRED).
8
8
  terms:
9
9
  - term_id: fallback
10
10
  text: >-
11
11
  a recorded switch from a preferred model to a later one in the stage's list, with role (writer or reviewer) and a reason: an outage, a rate limit, or no auditable report, counted by failures; or the operator's order, referenced by its ruling. A REQUEST CHANGES verdict is never a reason.
12
12
  content:
13
13
  summary: >-
14
- The lists are the operator's choice of who may do the work. A silent switch to another model hides an outage and can hide a reviewer chosen for being lenient.
14
+ The lists are the operator's choice of which models may do the work. Switching between allowed models needs no policy edit and no paperwork, so a rate limit or a preference change is handled on the spot; a model outside the list is still reported.
15
15
  normative_text: |
16
- The first model in a list is used normally. When it is unavailable, the driver falls back to the next one and records it. A fallback is never triggered by a verdict: shopping for a reviewer until one approves is exactly what this rule exists to expose.
17
- An operator can order a substitution faster than a policy change lands, as a standing ruling while a model is rate-limited. The record then states that ruling rather than failures nobody counted. The substitute must still be a model the stage lists in the policy the record is judged by, so the policy file keeps the final word; and only the operator rules: a driver never writes an operator fallback on its own authority.
16
+ The record must name the model that ran. Reviewer independence (a reviewer is never the model that wrote the work it reviews, under different-model) is judged by delivery.reviewer-independent, and a disputed finding upheld on a fresh review blocks the tranche, so switching reviewers cannot buy an approval.
17
+ A fallback entry (kind outage, rate_limit, no_report, timeout or operator) may still record why a model was used; it is optional and, when present, must be well formed.
18
18
  fix_hint: |
19
- Record the fallback on the review:
20
-
21
- fallback:
22
- - { role: reviewer, from: codex, kind: rate_limit, failures: 3, window: { from: "2026-09-25T09:00:00Z", to: "2026-09-25T09:08:00Z" }, reason: "429 on 3 attempts in 10 minutes" }
23
-
24
- or, for a substitution the operator ordered:
25
-
26
- - { role: writer, from: claude-opus, kind: operator, decided_by: "the owner", decided_at: "2026-09-27T23:00:00Z", ruling: "atdd-frg-workstation #abc123", reason: "Standing substitution while Claude is rate-limited: kimi for Opus-tier work" }
27
-
28
- or use a model the stage lists.
29
- exceptions:
30
- - >-
31
- When a stage lists one model, no fallback is possible: an unavailable model blocks the tranche.
19
+ Use a model the stage lists, or have the operator add it (or "*") to the stage's list in atdd-bun.yaml; the integrity check reports an added model once for the operator to approve.
32
20
  metadata:
33
21
  aliases:
34
22
  - DELIVERY-MODEL-ALLOWED-001
@@ -11,13 +11,13 @@ terms:
11
11
  Owns throughput, never the work. It splits the program into tranches with explicit dependencies and writes why, the scope and the split in docs/delivery/index.adoc; activates a tranche once its own dependencies have merged (one still waiting may run plan and its review, nothing after); starts one driver per tranche in its own worktree; keeps provider health for the program, telling every driver to skip a model that is down; intervenes when a tranche is idle without being blocked, or has deliverables and no commit after two hours; and hands BLOCKED disputed-finding to a human with both sides. It never writes, reviews or merges.
12
12
  - term_id: driver
13
13
  text: >-
14
- Owns one tranche end to end. For each stage of delivery.stages, in order, it starts the stage's writer (the first model in its writer list, or the next after a recorded fallback) in the tranche worktree, commits red on its own before green starts, passes the stage's gate, and appends a work entry; for each stage with a reviewer it starts a review. It never reviews.
14
+ Owns one tranche end to end. For each stage of delivery.stages, in order, it starts the stage's writer (any model in its writer list, as the operator chooses) in the tranche worktree, commits red on its own before green starts, passes the stage's gate, and appends a work entry; for each stage with a reviewer it starts a review. It never reviews.
15
15
  - term_id: review_run
16
16
  text: >-
17
17
  Every review is a fresh process in its own detached worktree at the exact SHA (git worktree add --detach), launched with a prompt that gives it the delivery.review convention, the stage, the base SHA, the SHA and the RED commit. Afterwards git status --porcelain must be empty and HEAD still the SHA, or the review is a REVIEWER_FAILURE and does not count. The raw output is kept in the tranche folder and named in the review's report.
18
18
  - term_id: fallback
19
19
  text: >-
20
- After fallback.after_failures failures within fallback.within_minutes (outage, rate limit, no auditable report, timeout), the driver uses the next model in the list and records the fallback with its role, kind, failures and window. A request-changes verdict is never a failure. With the list exhausted, when_exhausted block emits BLOCKED provider-unavailable; wait keeps retrying the last model.
20
+ Any model a stage lists may be used, so a switch needs no fallback: when a model is down or rate-limited, the driver moves to another listed model and records the one that ran. A fallback entry may still say why (outage, rate limit, no auditable report, timeout, or the operator's order); it is optional. A request-changes verdict is never a reason to switch reviewers: a disputed finding upheld on a fresh review blocks the tranche. When no listed model is available, when_exhausted block emits BLOCKED provider-unavailable.
21
21
  - term_id: repair
22
22
  text: >-
23
23
  For each finding of a request-changes review the writer either fixes it or writes one rebuttal with evidence; a fresh review of the same stage follows. If that reviewer upholds a disputed finding, the driver emits BLOCKED disputed-finding and never disputes it again. Any commit, regenerated file, conflict fix or rebase after an approval cancels it and the change goes back to that stage's review.
@@ -51,10 +51,12 @@ content:
51
51
  fix_hint: |
52
52
  Regenerate the registry rather than editing it:
53
53
 
54
- atdd docs build # once #1623 lands the operator verbs (spec 2 §7)
54
+ atdd-bun docs adr-register # rewrites the adr-register region
55
+ atdd-bun docs adr-register --check # reports drift without writing
55
56
 
56
- Until then the capability reports the delta and the registry is regenerated by the same code
57
- path the check uses, so the two can never disagree. If you find yourself typing an entry into
57
+ It writes only between `// BEGIN GENERATED: adr-register` and `// END GENERATED: adr-register`
58
+ in decisions/index.adoc (add the two markers where the list belongs), one line per ADR by
59
+ `:adr-id:`, from the same documents the check reads. If you find yourself typing an entry into
58
60
  `decisions/index.adoc`, the entry belongs in an ADR file with a `:decides:` attribute instead.
59
61
  exceptions:
60
62
  - Prose surrounding the derived entries in `index.adoc` (a title, an introductory paragraph) is not
package/integrity.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.10.26",
2
+ "version": "0.10.28",
3
3
  "files": {
4
4
  "HOOK_AUDIT.md": "5329d840db37671b1918f688ead26865473b87db73dbc75f7c8b2a8bbe8d6d43",
5
5
  "PLANNER_PORT.md": "fb5935bac8b7ac18994de21e43ace3a5ef8cd55f85b0e3349fca261280054f11",
@@ -94,16 +94,16 @@
94
94
  "conventions/coder.htmx/coder.htmx.verb-mutation-signals-progress.convention.yaml": "ae85c5ea01a09a3c41b939988088a8e5da91848d2710a1f0a1e0c86308638ac9",
95
95
  "conventions/delivery/delivery.approved-sha-resolves.convention.yaml": "68f2ecc10870e780abb05bb31d211b1923d47b56672df4b3950f437aa9cf8ca8",
96
96
  "conventions/delivery/delivery.board.convention.yaml": "4c544f51de80b09961a8df41028867023ab802aa4b31dcb0a1ce29e890552e6d",
97
- "conventions/delivery/delivery.config-schema.convention.yaml": "4c7b449074a8bd9f89b70c702ab3e0df229105ddb4458772f894f0bf37813e9f",
97
+ "conventions/delivery/delivery.config-schema.convention.yaml": "b59a69b7789ad0c3d0b1ac9ea2b955fbbb5dfa07a9ee169a037b406a198813f0",
98
98
  "conventions/delivery/delivery.evidence-schema.convention.yaml": "9c6a22495dfb3e2fcbca700392866c3b5660084b1f5594230eb320b499d5e5cb",
99
99
  "conventions/delivery/delivery.findings-resolved.convention.yaml": "f6574e93a9a2d4bdd6d6f5645720b1a647fc0048d95f7675fe783d2ddb62c05b",
100
100
  "conventions/delivery/delivery.merge-gate.convention.yaml": "48745ec23625b68be9b8fe2c6dcdcba88b2b38924f6593abf6987391ff0ee243",
101
- "conventions/delivery/delivery.model-allowed.convention.yaml": "7bf69b1803ec388a77b2fbc8aa727ff4ac6fd0cf4e5d5736519117bf86ac8688",
102
- "conventions/delivery/delivery.operating-model.convention.yaml": "7982157d957938ade878a114d3f5c2bdaa8f02e52ac21614634d9af4be9692f1",
101
+ "conventions/delivery/delivery.model-allowed.convention.yaml": "e9690e95f3604483f182b3032ae60eac20109eb458509f14651945ccf07692f2",
102
+ "conventions/delivery/delivery.operating-model.convention.yaml": "1a86a1fe84695968a2fb719a899629af9a8baadcd90bdb8776f1f0208cebbbb4",
103
103
  "conventions/delivery/delivery.review.convention.yaml": "b033579c2d90fcb098d325dcc07c7b084e048cca43fbe8e28159ec08a412018a",
104
104
  "conventions/delivery/delivery.reviewer-independent.convention.yaml": "8c436d66ef5b1f6d6bc5dd55a235caca2490020e88665eecadb2ca41cd4149c5",
105
105
  "conventions/delivery/delivery.stages-complete.convention.yaml": "0e5dd8057ad51eff7906e3424a9686a81be1814447afb9a30acab43d39e49b4c",
106
- "conventions/planner.docs/planner.docs.adr-registry-derived.convention.yaml": "0ea4232beba90280c664f93d097cafa44c67c2919c1468d4823fdc9e686aa5d2",
106
+ "conventions/planner.docs/planner.docs.adr-registry-derived.convention.yaml": "152cfb05c4a0e2053b09c05186d48ef330f4a94c5faddb8fbb322a81341a60a5",
107
107
  "conventions/planner.docs/planner.docs.area-index-required.convention.yaml": "fd931e9f225bfbb5e78436d0d8a2a89cc0f5114493579962c72dd971301becce",
108
108
  "conventions/planner.docs/planner.docs.artifact-path-shape.convention.yaml": "a9f6178d29abe9385342a46a2c57470a3631aaf5e26f01e3660b7088b042418e",
109
109
  "conventions/planner.docs/planner.docs.asciidoc-only.convention.yaml": "9e6fadbbe9aaf6015afc479f5b392e4c4324bfc47e1c59e315d26c2e5180a5f8",
@@ -1179,7 +1179,7 @@
1179
1179
  "planner-schemas/binding-lock.schema.json": "f2455606702f45fafa194549126d4a66e6c286ba1c4ba9e6bdcf932286a6d34d",
1180
1180
  "planner-schemas/component.schema.json": "d20d3502c155627a120cb10d5e9bdec4ba2c73af9c3e9a9d10e3c351f167c72a",
1181
1181
  "planner-schemas/contract-registry.schema.json": "b9dbaf6c87d4f4d676e3b4251344b9eada523b1c5e739c0423cabe267012c12e",
1182
- "planner-schemas/delivery-config.schema.json": "16a9b66a7d36edf8a49c68d9786f2516cd2ff5392be6dd60b766a09ffc95a9c6",
1182
+ "planner-schemas/delivery-config.schema.json": "7cd9c15ea4345ad83195b9a1c7d09a0b66275e9a3fdf8b5d6905d96bbec579d3",
1183
1183
  "planner-schemas/delivery-evidence.schema.json": "72ba1450abe5482c43f2069e9ee7d121398008a0ddef36c6b269d38a16872e0e",
1184
1184
  "planner-schemas/feature.schema.json": "eb4aa40d89067ae5c86a079bb3d8e4f45c8727f9e15db2b86c08dacf8e4ae56a",
1185
1185
  "planner-schemas/journey.schema.json": "1ff705bcf89a2823cf60c9e1597e254f97844e7a74dc58299d53192ad2c9f8c3",
@@ -1196,11 +1196,11 @@
1196
1196
  "relationships.yaml": "1cb713364aff54fb6e299e0737fe75a5d7b2e1dbd0a79b16b793aa1d269207b5",
1197
1197
  "src/agent.ts": "65e4579adb5a54977f557f8c783aba0e2a2a1d5cf3cea9f017b67ffe598b2f65",
1198
1198
  "src/board-ui.ts": "16a843705552f8f18e0e328e90e4a2a9148d771ca69f2708fe4a91302947dfad",
1199
- "src/board.ts": "72893289118461fbcee460d43f91e93fe4b078cabf171c03152c447eb8b71bb9",
1199
+ "src/board.ts": "bc70e479874f2f05a44293226e53eb9e763b661bdcebade4f8948b0dd2ee2ead",
1200
1200
  "src/ci.ts": "67e54d2cbfc44a9e42837d5e750af526d6255c539d3c452a4d5fca3226c0d9ac",
1201
- "src/cli.ts": "c1a7becd490ef9bf024569a869abc3b62ef48c0382897455e40fac9142d5ce46",
1202
- "src/delivery.ts": "f6bb6a7c42693d795b964c77b6186e1ca3a1556fd43932911a0ad42e72e7e441",
1203
- "src/docs-capability.ts": "115cf27049a5cf19c133bac5ec237072be6f969a59a2ef6767e054108a21dc5c",
1201
+ "src/cli.ts": "e5ab4d2435fdcbebf462b4c2b67b495bd975d5deb668b088d8cba04a9d8b78a5",
1202
+ "src/delivery.ts": "2eaaf4fd87aea26dce70b4c88a4e0a2f25b9de165bc2e38330fd7eb449587901",
1203
+ "src/docs-capability.ts": "8a7a4360976773fe462b1860eaf26182b016bcb1fbcd6a6d5423bd81379dbe67",
1204
1204
  "src/enforce.ts": "41a6f8c058a2a034996c817f224191403cc66ccc4ed4e04bbefc430014c783d8",
1205
1205
  "src/hooks.ts": "2feeec2674c1462c94256de02ce59ccc6e3ff671ec4dca1bf9903c32888b600b",
1206
1206
  "src/index.ts": "8ee4d7716794f6990ae8580e471b98256a0d0223dad063cd3b4542e598b6e744",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-bun",
3
- "version": "0.10.26",
3
+ "version": "0.10.28",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/afokapu/atdd-bun.git"
@@ -76,8 +76,8 @@
76
76
  "type": "array",
77
77
  "minItems": 1,
78
78
  "uniqueItems": true,
79
- "items": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" },
80
- "description": "Models in preference order: the first is used normally, each later one only as a recorded fallback."
79
+ "items": { "type": "string", "pattern": "^(\\*|[a-z][a-z0-9-]*)$" },
80
+ "description": "The models allowed for the role, in no order: any listed model may do the work, and \"*\" allows any model."
81
81
  },
82
82
  "stage": {
83
83
  "type": "object",
package/src/board.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { readFile } from "node:fs/promises";
5
5
  import { join } from "node:path";
@@ -132,6 +132,16 @@ function cursorFile(agent: string, topics: string[], env: Record<string, string
132
132
  return join(env.ATDD_BOARD_STATE || join(homedir(), ".atdd-board", "cursors"), key);
133
133
  }
134
134
  const loadCursor = (file: string) => { try { return readFileSync(file, "utf8").trim() || undefined; } catch { return undefined; } };
135
+ /** The place to start from on a topic set this identity has never listened to: its OLDEST saved place on any other set,
136
+ * so nothing unread is skipped and nothing older is replayed. Without one, the start of the board, as before. */
137
+ function seedCursor(agent: string, env: Record<string, string | undefined>): string | undefined {
138
+ const dir = env.ATDD_BOARD_STATE || join(homedir(), ".atdd-board", "cursors"), prefix = `${agent}__`.replace(/[^A-Za-z0-9@+_.-]/g, "_");
139
+ try {
140
+ const saved = readdirSync(dir).filter(name => name.startsWith(prefix)).map(name => ({ file: join(dir, name), at: statSync(join(dir, name)).mtimeMs })).sort((a, b) => a.at - b.at);
141
+ for (const { file } of saved) { const id = loadCursor(file); if (id) return id; }
142
+ } catch { /* no saved places */ }
143
+ return undefined;
144
+ }
135
145
  const saveCursor = (file: string, id: string) => { try { mkdirSync(join(file, ".."), { recursive: true }); writeFileSync(file, `${id}\n`); } catch { /* best effort */ } };
136
146
 
137
147
  export function format(message: Message, withTopic = false): string {
@@ -159,7 +169,7 @@ export async function chat(args: string[], env: Record<string, string | undefine
159
169
  if (command === "wait") {
160
170
  // Without --since, continue from where this identity last stopped on these topics; either way, remember the place.
161
171
  const file = cursorFile(me, topics, env);
162
- const message = await waitFor(url, topics, me, flag("since") || loadCursor(file) || "all", Number(flag("timeout") ?? 0), list(flag("skip")), 500, id => saveCursor(file, id));
172
+ const message = await waitFor(url, topics, me, flag("since") || loadCursor(file) || seedCursor(me, env) || "all", Number(flag("timeout") ?? 0), list(flag("skip")), 500, id => saveCursor(file, id));
163
173
  if (!message) { console.error(`no message for ${me} on ${topics.join(", ")} yet; run the same wait again to continue`); return 2; }
164
174
  console.log(format(message, topics.length > 1));
165
175
  return 0;
@@ -169,7 +179,7 @@ export async function chat(args: string[], env: Record<string, string | undefine
169
179
  // this identity, as it arrives, until --timeout (seconds; none = until stopped). Same place-keeping as wait.
170
180
  const file = cursorFile(me, topics, env), skip = list(flag("skip")), total = Number(flag("timeout") ?? 0);
171
181
  const deadline = total > 0 ? Date.now() + total * 1000 : Infinity;
172
- let since = flag("since") || loadCursor(file) || "all";
182
+ let since = flag("since") || loadCursor(file) || seedCursor(me, env) || "all";
173
183
  for (;;) {
174
184
  const left = Math.ceil((deadline - Date.now()) / 1000);
175
185
  if (left <= 0) return 0;
package/src/cli.ts CHANGED
@@ -5,6 +5,7 @@ import { ciInit, ciStatus } from "./ci";
5
5
  import { agentInit, agentStatus } from "./agent";
6
6
  import { checkIntegrity, formatIntegrity, integrityInit, integrityStatus } from "./integrity";
7
7
  import { journeyDocs } from "./journey-docs";
8
+ import { adrRegister } from "./docs-capability";
8
9
  import { releaseCheck } from "./release";
9
10
  import { initializeRepository } from "./setup";
10
11
  import { chat } from "./board";
@@ -21,6 +22,7 @@ const usage = {
21
22
  "atdd-bun agent <init|status> [--replace]",
22
23
  "atdd-bun integrity [init|status] [--replace]",
23
24
  "atdd-bun docs journeys [--out <dir>] [--check] [--force]",
25
+ "atdd-bun docs adr-register [--check]",
24
26
  "atdd-bun release check",
25
27
  "atdd-bun chat topic <program> [<tranche> [<stage> <round>]]",
26
28
  "atdd-bun chat post <topic> --to <agent,...> [--kind K] [--stage S] [--reply-to ID] [--repo R] [--branch B] [--worktree W] [--goal G] (body on stdin)",
@@ -77,8 +79,12 @@ if (args[0] === "ci") {
77
79
  const result = args[1] === "init" ? await ciInit(process.cwd(), args.includes("--replace")) : args[1] === "status" ? await ciStatus() : fail("ci requires init or status");
78
80
  console[result.ok ? "log" : "error"](result.message); process.exit(result.ok ? 0 : 1);
79
81
  }
82
+ if (args[0] === "docs" && args[1] === "adr-register") {
83
+ const result = await adrRegister(process.cwd(), args.includes("--check"));
84
+ console[result.ok ? "log" : "error"](result.message); process.exit(result.ok ? 0 : 1);
85
+ }
80
86
  if (args[0] === "docs") {
81
- if (args[1] !== "journeys") fail("docs requires journeys");
87
+ if (args[1] !== "journeys") fail("docs requires journeys or adr-register");
82
88
  const at = args.indexOf("--out"), out = at === -1 ? undefined : args[at + 1];
83
89
  if (at !== -1 && !out) fail("--out requires a directory");
84
90
  const result = await journeyDocs({ out, check: args.includes("--check"), force: args.includes("--force") });
package/src/delivery.ts CHANGED
@@ -39,7 +39,7 @@ export type DeliveryPolicy = {
39
39
  };
40
40
 
41
41
  /** The default operating model: two reviews, the plan and the whole change; the stages between them are written and
42
- * held by their deterministic gates. Lists are preference orders; a later model is used only as a recorded fallback. */
42
+ * held by their deterministic gates. Each list is the set of models allowed for the role, in no order; "*" allows any. */
43
43
  const DEFAULT_STAGES: Partial<Record<Stage, { writer?: string[]; reviewer?: string[] }>> = {
44
44
  plan: { writer: ["codex", "claude-opus", "kimi"], reviewer: ["glm", "claude-opus", "codex", "kimi"] },
45
45
  red: { writer: ["glm", "claude-sonnet", "deepseek-flash", "claude-opus", "codex", "kimi"] },
@@ -122,10 +122,7 @@ export function loosenedDelivery(base: unknown, current: unknown): string[] {
122
122
  for (const role of ["reviewer", "writer"] as const) {
123
123
  const added = c[role].filter(model => !b[role].includes(model));
124
124
  if (added.length && b[role].length) out.push(`delivery.stages.${stage}.${role} adds ${added.join(", ")}`);
125
- // The lists are preference orders: moving a model earlier, by reordering or removing one before it, makes a
126
- // fallback model usable without the fallback.
127
- const promoted = c[role].filter(model => b[role].includes(model) && c[role].indexOf(model) < b[role].indexOf(model));
128
- if (promoted.length) out.push(`delivery.stages.${stage}.${role} [${b[role].join(", ")}] → [${c[role].join(", ")}] promotes ${promoted.join(", ")}`);
125
+ // The lists are allowed sets: reordering one changes nothing; only an added model (or "*") is a loosening.
129
126
  }
130
127
  // A stage that had no writer list now accepting writers is a loosening too: before, nothing could be written there.
131
128
  if (!b.writer.length && c.writer.length) out.push(`delivery.stages.${stage}.writer adds ${c.writer.join(", ")}`);
@@ -235,18 +232,12 @@ export async function loadEvidence(root: string, policy: DeliveryPolicy): Promis
235
232
  return out;
236
233
  }
237
234
 
238
- /** A model must come from the stage's list; a model after the first needs a recorded fallback from each one before it,
239
- * except one the stage could not use: under different-model independence a reviewer skips every model that wrote the
240
- * work it reviews (delivery.reviewer-independent's fix hint). Ineligible is not unavailable, so no fallback records it. */
241
- function checkModel(file: string, at: string, stage: Stage, role: "writer" | "reviewer", actor: Actor, list: string[], fallbacks: Fallback[], defaultRole: "writer" | "reviewer", fallback: DeliveryPolicy["fallback"], ineligible: string[] = []): PlanFinding[] {
242
- const out: PlanFinding[] = [];
243
- const index = list.indexOf(actor.model);
244
- if (index < 0) return [finding("delivery.model-allowed", file, `${at}: ${role} model '${actor.model}' is not in ${stage}.${role} [${list.join(", ")}]`)];
245
- // `author` is the 0.9 spelling of `writer`.
246
- const recorded = fallbacks.filter(entry => ((entry.role === "author" ? "writer" : entry.role) ?? defaultRole) === role).map(entry => entry.from);
247
- for (const skipped of list.slice(0, index)) if (!recorded.includes(skipped) && !ineligible.includes(skipped)) out.push(finding("delivery.model-allowed", file, `${at}: ${role} '${actor.model}' is a fallback, but no fallback from '${skipped}' records why it was unavailable`));
248
- for (const from of recorded) if (!list.slice(0, index).includes(from)) out.push(finding("delivery.model-allowed", file, `${at}: ${role} fallback from '${from}' does not precede '${actor.model}' in [${list.join(", ")}]`));
249
- return out;
235
+ /** A model must be one the stage allows: its list is the set of allowed models, in no order, and "*" allows any. Which
236
+ * listed model does the work is the operator's choice at any moment, so no fallback has to explain it; the record still
237
+ * names the model that ran, and reviewer independence is judged separately (delivery.reviewer-independent). */
238
+ function checkModel(file: string, at: string, stage: Stage, role: "writer" | "reviewer", actor: Actor, list: string[], _fallbacks: Fallback[], _defaultRole: "writer" | "reviewer", _fallback: DeliveryPolicy["fallback"], _ineligible: string[] = []): PlanFinding[] {
239
+ if (list.includes("*") || list.includes(actor.model)) return [];
240
+ return [finding("delivery.model-allowed", file, `${at}: ${role} model '${actor.model}' is not allowed for ${stage}.${role} [${list.join(", ")}]`)];
250
241
  }
251
242
 
252
243
  /** The count and window of every recorded fallback are the driver's claims, but explicit ones: outside the policy is out of policy. */
@@ -184,3 +184,26 @@ export async function checkDocumentation(input: { root: string; declaration: Doc
184
184
  try { return await checkDocumentationInner(input); }
185
185
  catch (error) { return { verdict: "FAIL", findings: [{ rule_id: null, where: input.root, message: `the documentation capability raised ${error instanceof Error ? error.name : "Error"}: ${String(error)}. A capability that crashes has not discharged the obligation.` }], checked: [] }; }
186
186
  }
187
+
188
+ const ADR_REGION = /^(\/\/\s*BEGIN GENERATED:\s*adr-register\b[^\n]*\n)[\s\S]*?(^\/\/\s*END GENERATED:\s*adr-register\b[^\n]*$)/m;
189
+
190
+ /** Regenerates the ADR register: the `// BEGIN GENERATED: adr-register` … `// END GENERATED: adr-register` region of
191
+ * docs/architecture/decisions/index.adoc, one line per ADR by :adr-id:, which planner.docs.adr-registry-derived
192
+ * requires to be projected, never typed. `check` reports drift without writing. Without the markers it writes nothing
193
+ * and says where they go: where the list belongs in the index is the corpus's choice. */
194
+ export async function adrRegister(root = process.cwd(), check = false): Promise<{ ok: boolean; message: string }> {
195
+ const index = join(root, ADR_INDEX);
196
+ if (!existsSync(index)) return { ok: false, message: `${ADR_INDEX} does not exist; create it with the adr-register markers where the list belongs` };
197
+ const text = await readFile(index, "utf8");
198
+ if (!ADR_REGION.test(text)) return { ok: false, message: `${ADR_INDEX} has no adr-register region; add "// BEGIN GENERATED: adr-register" and "// END GENERATED: adr-register" on their own lines where the list belongs` };
199
+ const adr = (await documents(root)).filter(d => d.path.startsWith(`${ADR_DIR}/`) && /^adr-\d{8}-\d{3}-[a-z0-9][a-z0-9-]*\.adoc$/.test(basename(d.path)) && d.attrs["adr-id"]);
200
+ const lines = adr.sort((a, b) => a.attrs["adr-id"].localeCompare(b.attrs["adr-id"])).map(d => {
201
+ const title = d.text.match(/^=\s+(.+)$/m)?.[1]?.trim() ?? d.attrs["adr-id"], status = d.attrs.status ? ` (${d.attrs.status})` : "";
202
+ return `* xref:${basename(d.path)}[${d.attrs["adr-id"]}] ${title}${status}`;
203
+ });
204
+ const next = text.replace(ADR_REGION, (_m, begin: string, end: string) => `${begin}${lines.join("\n")}${lines.length ? "\n" : ""}${end}`);
205
+ if (next === text) return { ok: true, message: `ADR register is current (${lines.length} ADRs)` };
206
+ if (check) return { ok: false, message: `ADR register has drifted; run atdd-bun docs adr-register` };
207
+ await Bun.write(index, next);
208
+ return { ok: true, message: `ADR register regenerated (${lines.length} ADRs)` };
209
+ }