@afokapu/atdd-bun 0.10.15 → 0.10.17

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.
@@ -4,22 +4,27 @@ 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 (REQUIRED).
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).
8
8
  terms:
9
9
  - term_id: fallback
10
10
  text: >-
11
- a recorded switch from a preferred model to the next one in the stage's list, with role (writer or reviewer) and a reason: an outage, a rate limit, or no auditable report. A REQUEST CHANGES verdict is never a reason.
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
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.
15
15
  normative_text: |
16
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.
17
18
  fix_hint: |
18
19
  Record the fallback on the review:
19
20
 
20
21
  fallback:
21
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" }
22
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
+
23
28
  or use a model the stage lists.
24
29
  exceptions:
25
30
  - >-
package/integrity.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.10.15",
2
+ "version": "0.10.17",
3
3
  "files": {
4
4
  "HOOK_AUDIT.md": "5329d840db37671b1918f688ead26865473b87db73dbc75f7c8b2a8bbe8d6d43",
5
5
  "PLANNER_PORT.md": "fb5935bac8b7ac18994de21e43ace3a5ef8cd55f85b0e3349fca261280054f11",
@@ -98,7 +98,7 @@
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": "1071a727f61bacd21d70921885826974234ef0a58c0a814454b9375a0432b0fe",
101
+ "conventions/delivery/delivery.model-allowed.convention.yaml": "7bf69b1803ec388a77b2fbc8aa727ff4ac6fd0cf4e5d5736519117bf86ac8688",
102
102
  "conventions/delivery/delivery.operating-model.convention.yaml": "7982157d957938ade878a114d3f5c2bdaa8f02e52ac21614634d9af4be9692f1",
103
103
  "conventions/delivery/delivery.review.convention.yaml": "b033579c2d90fcb098d325dcc07c7b084e048cca43fbe8e28159ec08a412018a",
104
104
  "conventions/delivery/delivery.reviewer-independent.convention.yaml": "8c436d66ef5b1f6d6bc5dd55a235caca2490020e88665eecadb2ca41cd4149c5",
@@ -1178,7 +1178,7 @@
1178
1178
  "planner-schemas/component.schema.json": "d20d3502c155627a120cb10d5e9bdec4ba2c73af9c3e9a9d10e3c351f167c72a",
1179
1179
  "planner-schemas/contract-registry.schema.json": "b9dbaf6c87d4f4d676e3b4251344b9eada523b1c5e739c0423cabe267012c12e",
1180
1180
  "planner-schemas/delivery-config.schema.json": "16a9b66a7d36edf8a49c68d9786f2516cd2ff5392be6dd60b766a09ffc95a9c6",
1181
- "planner-schemas/delivery-evidence.schema.json": "291c2c09f0baa3ca037ab073efd998bdc62fb7af62cc0e33ee26949d26eed518",
1181
+ "planner-schemas/delivery-evidence.schema.json": "72ba1450abe5482c43f2069e9ee7d121398008a0ddef36c6b269d38a16872e0e",
1182
1182
  "planner-schemas/feature.schema.json": "eb4aa40d89067ae5c86a079bb3d8e4f45c8727f9e15db2b86c08dacf8e4ae56a",
1183
1183
  "planner-schemas/journey.schema.json": "1ff705bcf89a2823cf60c9e1597e254f97844e7a74dc58299d53192ad2c9f8c3",
1184
1184
  "planner-schemas/registry-index.schema.json": "ffd5643da662ced4052707eeb270eb02908f2e48365103ff0638ec7ba146f430",
@@ -1197,7 +1197,7 @@
1197
1197
  "src/board.ts": "a3a8efe923858b9b5cf5ca3682dacfceb5557f8303d818687eb1325a15fa1782",
1198
1198
  "src/ci.ts": "67e54d2cbfc44a9e42837d5e750af526d6255c539d3c452a4d5fca3226c0d9ac",
1199
1199
  "src/cli.ts": "e45f2666334503a91eb65b1b5ee4dd8acab6b99f62d333e2ee2b65963db6358b",
1200
- "src/delivery.ts": "5688356e26bf5aa49ad940d1bcd42e41b9f3dc1c21dc833b328fcb38a3f04fb8",
1200
+ "src/delivery.ts": "f6bb6a7c42693d795b964c77b6186e1ca3a1556fd43932911a0ad42e72e7e441",
1201
1201
  "src/docs-capability.ts": "115cf27049a5cf19c133bac5ec237072be6f969a59a2ef6767e054108a21dc5c",
1202
1202
  "src/enforce.ts": "41a6f8c058a2a034996c817f224191403cc66ccc4ed4e04bbefc430014c783d8",
1203
1203
  "src/hooks.ts": "2feeec2674c1462c94256de02ce59ccc6e3ff671ec4dca1bf9903c32888b600b",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-bun",
3
- "version": "0.10.15",
3
+ "version": "0.10.17",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/afokapu/atdd-bun.git"
@@ -30,12 +30,16 @@
30
30
  },
31
31
  "fallback": {
32
32
  "type": "object",
33
- "required": ["from", "kind", "failures", "window", "reason"],
33
+ "required": ["from", "kind", "reason"],
34
34
  "additionalProperties": false,
35
+ "description": "Why a later model in the stage's list was used: counted failures of the preferred one, or the operator's order (kind operator), which references its ruling instead and is accepted only for a model the stage lists.",
36
+ "if": { "required": ["kind"], "properties": { "kind": { "const": "operator" } } },
37
+ "then": { "required": ["decided_by", "decided_at", "ruling"], "not": { "anyOf": [{ "required": ["failures"] }, { "required": ["window"] }] } },
38
+ "else": { "required": ["failures", "window"] },
35
39
  "properties": {
36
40
  "role": { "enum": ["writer", "author", "reviewer"], "description": "Which actor fell back; author is the 0.9 spelling of writer. Default: writer on a work entry, reviewer on a review." },
37
41
  "from": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" },
38
- "kind": { "enum": ["outage", "rate_limit", "no_report", "timeout"], "description": "Why the model counts as unavailable. A verdict is never one of these." },
42
+ "kind": { "enum": ["outage", "rate_limit", "no_report", "timeout", "operator"], "description": "Why the model counts as unavailable, or operator when the operator ordered the substitution. A verdict is never one of these." },
39
43
  "failures": { "type": "integer", "minimum": 1, "description": "Failures observed before falling back; at least delivery.fallback.after_failures." },
40
44
  "window": {
41
45
  "type": "object",
@@ -44,7 +48,10 @@
44
48
  "description": "When the first and last counted failures happened; the span is at most delivery.fallback.within_minutes.",
45
49
  "properties": { "from": { "type": "string", "format": "date-time" }, "to": { "type": "string", "format": "date-time" } }
46
50
  },
47
- "reason": { "type": "string", "minLength": 10, "description": "Why the preferred model was unavailable: outage, rate limit, or no auditable report. Never a verdict." }
51
+ "decided_by": { "type": "string", "minLength": 2, "description": "operator only: who ruled, by name or board identity. Never the driver on its own authority." },
52
+ "decided_at": { "type": "string", "format": "date-time", "description": "operator only: when the ruling was given." },
53
+ "ruling": { "type": "string", "minLength": 3, "description": "operator only: where the ruling is recorded (a board message id, an ADR or a PR)." },
54
+ "reason": { "type": "string", "minLength": 10, "description": "Why the preferred model was not used: an outage, a rate limit, no auditable report, or the operator's order. Never a verdict." }
48
55
  }
49
56
  },
50
57
  "finding": {
package/src/delivery.ts CHANGED
@@ -177,7 +177,8 @@ const git = async (cwd: string, args: string[]) => {
177
177
  };
178
178
 
179
179
  type Actor = { model: string; run: string };
180
- type Fallback = { role?: "author" | "writer" | "reviewer"; from: string; kind: string; failures: number; window: { from: string; to: string }; reason: string };
180
+ // A failure fallback counts observed failures; an operator fallback references the operator's ruling instead.
181
+ type Fallback = { role?: "author" | "writer" | "reviewer"; from: string; kind: string; failures?: number; window?: { from: string; to: string }; decided_by?: string; decided_at?: string; ruling?: string; reason: string };
181
182
  type Work = { stage: Stage; sha: string; writer: Actor; fallback?: Fallback[] };
182
183
  type Review = { stage: Stage; sha: string; author?: Actor; reviewer: Actor; fallback?: Fallback[]; verdict: "approve" | "request_changes"; findings?: Finding[]; report?: string };
183
184
  type Finding = { id: string; severity: string; rebuttal?: string; outcome?: "fixed" | "withdrawn" | "human"; decision?: string };
@@ -234,14 +235,16 @@ export async function loadEvidence(root: string, policy: DeliveryPolicy): Promis
234
235
  return out;
235
236
  }
236
237
 
237
- /** A model must come from the stage's list; a model after the first needs a recorded fallback from each one before it. */
238
- function checkModel(file: string, at: string, stage: Stage, role: "writer" | "reviewer", actor: Actor, list: string[], fallbacks: Fallback[], defaultRole: "writer" | "reviewer", fallback: DeliveryPolicy["fallback"]): PlanFinding[] {
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[] {
239
242
  const out: PlanFinding[] = [];
240
243
  const index = list.indexOf(actor.model);
241
244
  if (index < 0) return [finding("delivery.model-allowed", file, `${at}: ${role} model '${actor.model}' is not in ${stage}.${role} [${list.join(", ")}]`)];
242
245
  // `author` is the 0.9 spelling of `writer`.
243
246
  const recorded = fallbacks.filter(entry => ((entry.role === "author" ? "writer" : entry.role) ?? defaultRole) === role).map(entry => entry.from);
244
- for (const skipped of list.slice(0, index)) if (!recorded.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`));
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`));
245
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(", ")}]`));
246
249
  return out;
247
250
  }
@@ -250,6 +253,8 @@ function checkModel(file: string, at: string, stage: Stage, role: "writer" | "re
250
253
  function checkFallbacks(file: string, at: string, fallbacks: Fallback[], fallback: DeliveryPolicy["fallback"]): PlanFinding[] {
251
254
  const out: PlanFinding[] = [];
252
255
  for (const entry of fallbacks) {
256
+ // The operator ordered the substitution: nothing was counted, and the schema has required the ruling it names.
257
+ if (entry.kind === "operator" || !entry.window || entry.failures === undefined) continue;
253
258
  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)`));
254
259
  const span = (Date.parse(entry.window.to) - Date.parse(entry.window.from)) / 60_000;
255
260
  if (!(span >= 0)) out.push(finding("delivery.model-allowed", file, `${at}: fallback from '${entry.from}' has a window that ends before it starts`));
@@ -305,10 +310,11 @@ function checkEvidence(file: string, evidence: Evidence, policy: DeliveryPolicy)
305
310
  const at = `reviews[${index}] (${review.stage} @ ${review.sha})`, stage = policy.stages[review.stage];
306
311
  if (!stage?.reviewer.length) { out.push(finding("delivery.evidence-schema", file, `${at}: ${review.stage} has no reviewer in delivery.stages`)); return; }
307
312
  const fallbacks = review.fallback ?? [];
308
- out.push(...checkFallbacks(file, at, fallbacks, policy.fallback), ...checkModel(file, at, review.stage, "reviewer", review.reviewer, stage.reviewer, fallbacks, "reviewer", policy.fallback));
309
- if (review.author && stage.writer.length) out.push(...checkModel(file, at, review.stage, "writer", review.author, stage.writer, fallbacks, "reviewer", policy.fallback));
310
313
  // Independence is judged against everyone who wrote what this review answers for.
311
314
  const covered = coveredStages(review.stage, policy, work), writers = [...work.filter(entry => covered.includes(entry.stage)).map(entry => entry.writer), ...(review.author ? [review.author] : [])];
315
+ const ineligible = stage.independence === "different-model" ? writers.map(writer => writer.model) : [];
316
+ out.push(...checkFallbacks(file, at, fallbacks, policy.fallback), ...checkModel(file, at, review.stage, "reviewer", review.reviewer, stage.reviewer, fallbacks, "reviewer", policy.fallback, ineligible));
317
+ if (review.author && stage.writer.length) out.push(...checkModel(file, at, review.stage, "writer", review.author, stage.writer, fallbacks, "reviewer", policy.fallback));
312
318
  if (!writers.length) out.push(finding("delivery.evidence-schema", file, `${at}: names no writer of the work it reviews (${covered.join(", ")}); record each stage's work, or the review's author, so independence can be judged`));
313
319
  if (writerRuns.has(review.reviewer.run)) out.push(finding("delivery.reviewer-independent", file, `${at}: reviewer run '${review.reviewer.run}' also wrote in this tranche; a reviewer that edits becomes a writer`));
314
320
  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`));