Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion plugins/stewards/lessons.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { hasLessonAnchor, lessonAnchor, lessonScore, planLessonTransitions, planValidations, removeLessonAnchor, retireLessonAnchor, retiredLessonAnchor, withLessonAnchor, type ScoreableLesson } from "./lessons.js";
import { hasLessonAnchor, landLessonInstruction, lessonAnchor, lessonLandingMark, lessonScore, planLessonTransitions, planValidations, removeLessonAnchor, retireLessonAnchor, retiredLessonAnchor, withLessonAnchor, type ScoreableLesson } from "./lessons.js";
import type { LessonRules } from "./config.js";

const DAY = 86_400_000;
Expand Down Expand Up @@ -137,3 +137,34 @@ describe("the validation queue (BB-35 step 5)", () => {
expect(planValidations([weak, strong], rules, T).map((r) => r.id)).toEqual(["lsn_strong", "lsn_weak"]);
});
});

describe("the landing decision (BB-288)", () => {
// The decide-list entries bb-gate `main` carries (BB-280): the `user` entry
// for AGENTS.md and the `project` entry for the playbooks. A lesson is placed
// by these, not by its scope, except that a boundary lesson stays the user's.
const decisions = [
{ text: "Who stewards are and what the project tells them (steward roles, the decide list, the project's guidance)", decide: "user" as const, paths: [".bb/stewards/roles.json", ".bb/stewards/project.json", ".bb/AGENTS.md"] },
{ text: "What every steward is told (the playbooks and the project's skills)", decide: "project" as const, paths: [".bb/stewards/playbooks/**", ".bb/skills/**"] },
];

it("places a procedure lesson by the ordinary decide list", () => {
expect(lessonLandingMark({ file: "stewards/playbooks/linear-tickets.md", scope: "procedure" }, decisions)).toBe("project");
expect(lessonLandingMark({ file: "AGENTS.md", scope: "procedure" }, decisions)).toBe("user");
});

it("keeps a boundary lesson the user's whatever file it targets", () => {
expect(lessonLandingMark({ file: "stewards/playbooks/linear-tickets.md", scope: "boundary" }, decisions)).toBe("user");
});

it("fails closed on no match or an unreadable decide list", () => {
expect(lessonLandingMark({ file: "stewards/playbooks/linear-tickets.md", scope: "procedure" }, [])).toBe("user");
expect(lessonLandingMark({ file: "docs/whatever.md", scope: "procedure" }, decisions)).toBe("user");
});

it("names the anchor, file and completion call for an early landing", () => {
const instruction = landLessonInstruction({ id: "lsn_1", file: "stewards/playbooks/linear-tickets.md" });
expect(instruction).toContain("<!-- lesson:lsn_1 -->");
expect(instruction).toContain("stewards/playbooks/linear-tickets.md");
expect(instruction).toContain('steward_update { lessonAction: { id: "lsn_1", action: "complete" } }');
});
});
26 changes: 25 additions & 1 deletion plugins/stewards/lessons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
// lessons, and the thresholds and caps that decide which tier a lesson moves
// to. Nothing here touches the store or a plugin; server.ts applies what this
// returns. Keeping it pure is what makes every transition a unit test.
import type { LessonScope, LessonTier } from "./store.js";
import type { Lesson, LessonScope, LessonTier } from "./store.js";
import type { LessonRules } from "./config.js";
import { decideForPull, type BigDecision, type DecideMark } from "./decisions.js";

const DAY_MS = 86_400_000;

Expand Down Expand Up @@ -166,6 +167,29 @@ export function planLessonTransitions(lessons: readonly ScoreableLesson[], rules
return out;
}

/**
* How a lesson's pull request is placed (BB-288): the ordinary decide list
* applied to the lesson's own `.bb` file, never a lesson-specific rule. A
* `boundary` lesson is always the user's, because it changes the escalation
* boundary. A `procedure` lesson in a file the decide list marks `project` (a
* playbook) is the project steward's; one in a `user` file (`AGENTS.md`, the
* project file, `roles.json`) is the owner's. The files are fail-closed: an
* unreadable list or no match is the user's, exactly as `decideForPull`.
*/
export function lessonLandingMark(lesson: Pick<Lesson, "file" | "scope">, decisions: readonly BigDecision[]): DecideMark {
if (lesson.scope === "boundary") return "user";
return decideForPull([lesson.file], decisions);
}

/**
* The sentence a wake and the record-time reply use to land a trial lesson
* early, before the nightly pass promotes it (BB-288). It names the anchor, the
* file and the completion call, so both surfaces say the same thing.
*/
export function landLessonInstruction(lesson: Pick<Lesson, "id" | "file">): string {
return `To land it early: add \`${lessonAnchor(lesson.id)}\` and the wording to \`${lesson.file}\`, publish with \`workspace_publish_guidance\` (on a native project, commit and open the pull request yourself), then complete it with \`steward_update { lessonAction: { id: "${lesson.id}", action: "complete" } }\`.`;
}

/** The two rare conditions a validation escalation asks the user about (BB-35 step 5). */
export const VALIDATION_KINDS = ["boundary-promotion", "mixed-evidence"] as const;
export type ValidationKind = (typeof VALIDATION_KINDS)[number];
Expand Down
Loading
Loading