From 4e9deae8149bb6c983f4987e4fc370b05759772c Mon Sep 17 00:00:00 2001 From: SuhaibAslam Date: Wed, 29 Jul 2026 20:14:08 +0200 Subject: [PATCH 1/3] feat: record review-gated project learnings --- README.md | 11 +++++ src/cli/commands/index.ts | 2 + src/cli/commands/learning.ts | 67 +++++++++++++++++++++++++ src/cli/commands/show.ts | 35 +++++++++++++ src/core/index.ts | 8 +++ src/core/schema.ts | 26 ++++++++++ src/core/trajectory.ts | 39 +++++++++++++++ src/core/types.d.ts | 36 ++++++++++++++ src/core/types.ts | 40 +++++++++++++++ src/index.ts | 8 +++ tests/cli/commands.test.ts | 92 +++++++++++++++++++++++++++++++++++ tests/core/trajectory.test.ts | 43 ++++++++++++++++ 12 files changed, 407 insertions(+) create mode 100644 src/cli/commands/learning.ts diff --git a/README.md b/README.md index 2725fb3..ad6b601 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,17 @@ trail status trail decision "Chose JWT over sessions" \ --reasoning "Stateless scaling requirements" +# Record a codebase learning. Candidates are queued for human review and never +# modify AGENTS.md, CLAUDE.md, or skills automatically. +trail learning "Auth validation belongs at the API boundary" \ + --source code-review \ + --area src/auth \ + --recurrence-key auth-validation \ + --promotion-candidate + +# Query project learnings separately from decisions and reflections +trail show traj_abc123 --learnings + # Complete with retrospective trail complete --summary "Added JWT auth" --confidence 0.85 diff --git a/src/cli/commands/index.ts b/src/cli/commands/index.ts index 3a69b2a..3132c49 100644 --- a/src/cli/commands/index.ts +++ b/src/cli/commands/index.ts @@ -27,6 +27,7 @@ import { registerDecisionCommand } from "./decision.js"; import { registerDoctorCommand } from "./doctor.js"; import { registerEnableCommand } from "./enable.js"; import { registerExportCommand } from "./export.js"; +import { registerLearningCommand } from "./learning.js"; import { registerListCommand } from "./list.js"; import { registerReflectCommand } from "./reflect.js"; import { registerShowCommand } from "./show.js"; @@ -42,6 +43,7 @@ export function registerCommands(program: Command): void { registerCompleteCommand(program); registerAbandonCommand(program); registerDecisionCommand(program); + registerLearningCommand(program); registerReflectCommand(program); registerListCommand(program); registerShowCommand(program); diff --git a/src/cli/commands/learning.ts b/src/cli/commands/learning.ts new file mode 100644 index 0000000..a450954 --- /dev/null +++ b/src/cli/commands/learning.ts @@ -0,0 +1,67 @@ +/** + * trail learning command + * + * Records codebase-specific learnings without modifying durable project + * instructions. Promotion candidates remain pending until a separate human + * review. + */ + +import type { Command } from "commander"; +import { addLearning } from "../../core/trajectory.js"; +import type { LearningSource } from "../../core/types.js"; +import { FileStorage } from "../../storage/file.js"; + +export function registerLearningCommand(program: Command): void { + program + .command("learning ") + .description("Record a project learning") + .requiredOption( + "-s, --source ", + "Origin: human-steer, pr-feedback, failed-attempt, code-review, or other", + ) + .requiredOption("-a, --area ", "Affected project area") + .option("-e, --evidence ", "Supporting evidence or reference") + .option( + "-k, --recurrence-key ", + "Stable key for grouping repeated learnings", + ) + .option( + "--promotion-candidate", + "Mark as pending human review (does not update project instructions)", + ) + .action(async (summary: string, options) => { + const storage = new FileStorage(); + await storage.initialize(); + + const active = await storage.getActive(); + if (!active) { + console.error("Error: No active trajectory"); + console.error('Start one with: trail start "Task description"'); + throw new Error("No active trajectory"); + } + + const promotionStatus = options.promotionCandidate + ? "pending_review" + : "archived"; + const updated = addLearning(active, { + summary, + source: options.source as LearningSource, + area: options.area, + evidence: options.evidence, + recurrenceKey: options.recurrenceKey, + promotionStatus, + }); + + await storage.save(updated); + + console.log(`✓ Learning recorded: ${summary}`); + console.log(` Source: ${options.source}`); + console.log(` Area: ${options.area}`); + console.log(` Promotion: ${promotionStatus}`); + if (promotionStatus === "pending_review") { + console.log( + " Human review required; no durable instructions were changed", + ); + } + }); +} diff --git a/src/cli/commands/show.ts b/src/cli/commands/show.ts index b67d60e..cac55f0 100644 --- a/src/cli/commands/show.ts +++ b/src/cli/commands/show.ts @@ -9,6 +9,7 @@ import type { Command } from "commander"; import { migrateTraceRecord } from "../../core/trace.js"; import type { Decision, + Learning, TraceConversation, TraceRecord, Trajectory, @@ -112,6 +113,7 @@ export function registerShowCommand(program: Command): void { .command("show ") .description("Show trajectory details") .option("-d, --decisions", "Show decisions only") + .option("--learnings", "Show project learnings only") .option("-t, --trace", "Show trace information") .action(async (id: string, options) => { const trajectory = await findTrajectory(id); @@ -195,6 +197,31 @@ export function registerShowCommand(program: Command): void { return; } + if (options.learnings) { + const learnings = extractLearnings(trajectory); + + if (learnings.length === 0) { + console.log("No project learnings recorded"); + return; + } + + console.log(`Project learnings for ${trajectory.task.title}:\n`); + for (const learning of learnings) { + console.log(`• ${learning.summary}`); + console.log(` Source: ${learning.source}`); + console.log(` Area: ${learning.area}`); + console.log(` Promotion: ${learning.promotionStatus}`); + if (learning.evidence) { + console.log(` Evidence: ${learning.evidence}`); + } + if (learning.recurrenceKey) { + console.log(` Recurrence key: ${learning.recurrenceKey}`); + } + console.log(""); + } + return; + } + // Show full details console.log(`Trajectory: ${trajectory.id}`); console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); @@ -227,6 +254,14 @@ export function registerShowCommand(program: Command): void { }); } +function extractLearnings(trajectory: Trajectory): Learning[] { + return trajectory.chapters.flatMap((chapter) => + chapter.events + .filter((event) => event.type === "learning" && event.raw) + .map((event) => event.raw as Learning), + ); +} + function extractDecisions(trajectory: any): Decision[] { const decisions: Decision[] = []; diff --git a/src/core/index.ts b/src/core/index.ts index 6f413a1..95bd5a6 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -8,6 +8,9 @@ export type { Chapter, TrajectoryEvent, Decision, + Learning, + LearningSource, + LearningPromotionStatus, Retrospective, TaskReference, TaskSource, @@ -19,6 +22,7 @@ export type { CreateTrajectoryInput, AddChapterInput, AddEventInput, + AddLearningInput, CompleteTrajectoryInput, TrajectoryQuery, // Trace types @@ -37,6 +41,9 @@ export { ChapterSchema, TrajectoryEventSchema, DecisionSchema, + LearningSchema, + LearningSourceSchema, + LearningPromotionStatusSchema, RetrospectiveSchema, validateTrajectory, validateCreateInput, @@ -57,6 +64,7 @@ export { addChapter, addEvent, addDecision, + addLearning, completeTrajectory, abandonTrajectory, } from "./trajectory.js"; diff --git a/src/core/schema.ts b/src/core/schema.ts index 267bb69..805d7bb 100644 --- a/src/core/schema.ts +++ b/src/core/schema.ts @@ -59,6 +59,7 @@ export const TrajectoryEventTypeSchema = z.union([ z.literal("message_received"), z.literal("decision"), z.literal("finding"), + z.literal("learning"), z.literal("reflection"), z.literal("note"), z.literal("error"), @@ -118,6 +119,31 @@ export const DecisionSchema = z.object({ .optional(), }); +/** + * Project learning schemas + */ +export const LearningSourceSchema = z.enum([ + "human-steer", + "pr-feedback", + "failed-attempt", + "code-review", + "other", +]); + +export const LearningPromotionStatusSchema = z.enum([ + "archived", + "pending_review", +]); + +export const LearningSchema = z.object({ + summary: z.string().min(1, "Learning summary is required"), + source: LearningSourceSchema, + area: z.string().min(1, "Affected area is required"), + evidence: z.string().min(1).optional(), + recurrenceKey: z.string().min(1).optional(), + promotionStatus: LearningPromotionStatusSchema, +}); + /** * Agent participation schema */ diff --git a/src/core/trajectory.ts b/src/core/trajectory.ts index 0293dc4..799ec8d 100644 --- a/src/core/trajectory.ts +++ b/src/core/trajectory.ts @@ -9,10 +9,12 @@ import { generateChapterId, generateTrajectoryId } from "./id.js"; import { CompleteTrajectoryInputSchema, CreateTrajectoryInputSchema, + LearningSchema, } from "./schema.js"; import type { AddChapterInput, AddEventInput, + AddLearningInput, AgentParticipation, Chapter, CompleteTrajectoryInput, @@ -193,6 +195,43 @@ export function addDecision( }); } +/** + * Add a structured project learning to the trajectory. + * + * This only records a trajectory event. A pending-review candidate does not + * modify durable instruction files or imply that promotion was approved. + */ +export function addLearning( + trajectory: Trajectory, + learning: AddLearningInput, +): Trajectory { + const validation = LearningSchema.safeParse(learning); + if (!validation.success) { + const firstError = validation.error.issues[0]; + throw new TrajectoryError( + firstError.message, + "VALIDATION_ERROR", + "Check the learning fields and try again", + ); + } + + return addEvent(trajectory, { + type: "learning", + content: learning.summary, + raw: learning, + significance: + learning.promotionStatus === "pending_review" ? "high" : "medium", + tags: [ + `learning-source:${learning.source}`, + `learning-area:${learning.area}`, + `promotion:${learning.promotionStatus}`, + ...(learning.recurrenceKey + ? [`recurrence:${learning.recurrenceKey}`] + : []), + ], + }); +} + /** * Complete a trajectory with retrospective * @param trajectory - The trajectory to complete diff --git a/src/core/types.d.ts b/src/core/types.d.ts index ec3385b..420a6f0 100644 --- a/src/core/types.d.ts +++ b/src/core/types.d.ts @@ -53,6 +53,7 @@ export type TrajectoryEventType = | "message_received" | "decision" | "finding" + | "learning" | "reflection" | "note" | "error"; @@ -131,6 +132,41 @@ export interface Finding { /** Confidence in this finding (0-1) */ confidence?: number; } +/** + * Where a project learning originated. + */ +export type LearningSource = + | "human-steer" + | "pr-feedback" + | "failed-attempt" + | "code-review" + | "other"; +/** + * Promotion is deliberately two-stage: candidates require a separate review + * before any durable project instruction can be changed. + */ +export type LearningPromotionStatus = "archived" | "pending_review"; +/** + * A codebase-specific learning captured during a trajectory. + */ +export interface Learning { + /** Concise statement of what future work should know. */ + summary: string; + /** How the learning was discovered. */ + source: LearningSource; + /** File, component, workflow, or other affected project area. */ + area: string; + /** Supporting context, such as a review comment or failed attempt. */ + evidence?: string; + /** Stable key used to group repeated occurrences. */ + recurrenceKey?: string; + /** Archived by default; candidates remain pending until human review. */ + promotionStatus: LearningPromotionStatus; +} +/** + * Input for recording a project learning. + */ +export type AddLearningInput = Learning; /** * Agent participation record */ diff --git a/src/core/types.ts b/src/core/types.ts index 2a52baa..8b6f292 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -58,6 +58,7 @@ export type TrajectoryEventType = | "message_received" | "decision" | "finding" + | "learning" | "reflection" | "note" | "error" @@ -148,6 +149,45 @@ export interface Finding { confidence?: number; } +/** + * Where a project learning originated. + */ +export type LearningSource = + | "human-steer" + | "pr-feedback" + | "failed-attempt" + | "code-review" + | "other"; + +/** + * Promotion is deliberately two-stage: candidates require a separate review + * before any durable project instruction can be changed. + */ +export type LearningPromotionStatus = "archived" | "pending_review"; + +/** + * A codebase-specific learning captured during a trajectory. + */ +export interface Learning { + /** Concise statement of what future work should know. */ + summary: string; + /** How the learning was discovered. */ + source: LearningSource; + /** File, component, workflow, or other affected project area. */ + area: string; + /** Supporting context, such as a review comment or failed attempt. */ + evidence?: string; + /** Stable key used to group repeated occurrences. */ + recurrenceKey?: string; + /** Archived by default; candidates remain pending until human review. */ + promotionStatus: LearningPromotionStatus; +} + +/** + * Input for recording a project learning. + */ +export type AddLearningInput = Learning; + /** * Agent participation record */ diff --git a/src/index.ts b/src/index.ts index 3f5f705..d68170a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,6 +21,7 @@ export { addChapter, addEvent, addDecision, + addLearning, completeTrajectory, abandonTrajectory, TrajectoryError, @@ -34,6 +35,9 @@ export { ChapterSchema, TrajectoryEventSchema, DecisionSchema, + LearningSchema, + LearningSourceSchema, + LearningPromotionStatusSchema, RetrospectiveSchema, } from "./core/schema.js"; @@ -61,6 +65,9 @@ export type { Chapter, TrajectoryEvent, Decision, + Learning, + LearningSource, + LearningPromotionStatus, Retrospective, TaskReference, TaskSource, @@ -72,6 +79,7 @@ export type { CreateTrajectoryInput, AddChapterInput, AddEventInput, + AddLearningInput, CompleteTrajectoryInput, TrajectoryQuery, } from "./core/types.js"; diff --git a/tests/cli/commands.test.ts b/tests/cli/commands.test.ts index 97580bf..e0d404a 100644 --- a/tests/cli/commands.test.ts +++ b/tests/cli/commands.test.ts @@ -176,6 +176,98 @@ describe("CLI Commands", () => { }); }); + describe("trail learning", () => { + it("should record a structured promotion candidate for human review", async () => { + const { runCommand } = await import("../../src/cli/runner.js"); + await runCommand(["start", "Test task"]); + + const result = await runCommand([ + "learning", + "Keep auth validation at the API boundary", + "--source", + "code-review", + "--area", + "src/auth", + "--evidence", + "PR review #42", + "--recurrence-key", + "auth-validation-boundary", + "--promotion-candidate", + ]); + + expect(result.success).toBe(true); + expect(result.output).toContain("Promotion: pending_review"); + expect(result.output).toContain("Human review required"); + + const { FileStorage } = await import("../../src/storage/file.js"); + const storage = new FileStorage(tempDir); + await storage.initialize(); + const active = await storage.getActive(); + const event = active?.chapters[0]?.events[0]; + expect(event?.type).toBe("learning"); + expect(event?.raw).toMatchObject({ + source: "code-review", + area: "src/auth", + promotionStatus: "pending_review", + }); + }); + + it("should archive a one-off learning by default", async () => { + const { runCommand } = await import("../../src/cli/runner.js"); + await runCommand(["start", "Test task"]); + + const result = await runCommand([ + "learning", + "This fixture uses a legacy date format", + "--source", + "failed-attempt", + "--area", + "tests/fixtures", + ]); + + expect(result.success).toBe(true); + expect(result.output).toContain("Promotion: archived"); + }); + + it("should show learnings separately", async () => { + const { runCommand } = await import("../../src/cli/runner.js"); + await runCommand(["start", "Test task"]); + await runCommand([ + "learning", + "Keep auth validation at the API boundary", + "--source", + "human-steer", + "--area", + "src/auth", + ]); + const status = await runCommand(["status"]); + const id = status.output.match(/traj_[a-z0-9]+/)?.[0]; + + const result = await runCommand(["show", id!, "--learnings"]); + + expect(result.success).toBe(true); + expect(result.output).toContain("Project learnings for Test task"); + expect(result.output).toContain("Source: human-steer"); + expect(result.output).toContain("Promotion: archived"); + }); + + it("should reject unsupported learning sources", async () => { + const { runCommand } = await import("../../src/cli/runner.js"); + await runCommand(["start", "Test task"]); + + const result = await runCommand([ + "learning", + "Invalid source", + "--source", + "agent-guess", + "--area", + "src", + ]); + + expect(result.success).toBe(false); + }); + }); + describe("trail complete", () => { it("should complete the trajectory with retrospective", async () => { // Arrange diff --git a/tests/core/trajectory.test.ts b/tests/core/trajectory.test.ts index 4fb8ddc..9978739 100644 --- a/tests/core/trajectory.test.ts +++ b/tests/core/trajectory.test.ts @@ -318,6 +318,49 @@ describe("Trajectory", () => { }); }); + describe("addLearning", () => { + it("should record a pending-review learning without changing durable instructions", async () => { + const { addLearning, createTrajectory } = await import( + "../../src/core/trajectory.js" + ); + const trajectory = createTrajectory({ title: "Test task" }); + + const updated = addLearning(trajectory, { + summary: "Keep auth validation at the API boundary", + source: "code-review", + area: "src/auth", + evidence: "PR review #42", + recurrenceKey: "auth-validation-boundary", + promotionStatus: "pending_review", + }); + + const event = updated.chapters[0].events[0]; + expect(event.type).toBe("learning"); + expect(event.significance).toBe("high"); + expect(event.raw).toMatchObject({ + promotionStatus: "pending_review", + recurrenceKey: "auth-validation-boundary", + }); + expect(updated).not.toHaveProperty("instructions"); + }); + + it("should reject an unsupported promotion status", async () => { + const { addLearning, createTrajectory } = await import( + "../../src/core/trajectory.js" + ); + const trajectory = createTrajectory({ title: "Test task" }); + + expect(() => + addLearning(trajectory, { + summary: "Update durable instructions immediately", + source: "other", + area: "AGENTS.md", + promotionStatus: "approved", + } as never), + ).toThrow(); + }); + }); + describe("completeTrajectory", () => { it("should mark trajectory as completed with retrospective", async () => { // Arrange From 6b3c0f0539bd1fad8bc6ded36a9ca877610f283e Mon Sep 17 00:00:00 2001 From: SuhaibAslam Date: Thu, 30 Jul 2026 16:05:54 +0200 Subject: [PATCH 2/3] fix: persist validated learning data --- src/core/trajectory.ts | 17 +++++++++-------- tests/core/trajectory.test.ts | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/src/core/trajectory.ts b/src/core/trajectory.ts index 799ec8d..3f8fc8d 100644 --- a/src/core/trajectory.ts +++ b/src/core/trajectory.ts @@ -214,19 +214,20 @@ export function addLearning( "Check the learning fields and try again", ); } + const learningData = validation.data; return addEvent(trajectory, { type: "learning", - content: learning.summary, - raw: learning, + content: learningData.summary, + raw: learningData, significance: - learning.promotionStatus === "pending_review" ? "high" : "medium", + learningData.promotionStatus === "pending_review" ? "high" : "medium", tags: [ - `learning-source:${learning.source}`, - `learning-area:${learning.area}`, - `promotion:${learning.promotionStatus}`, - ...(learning.recurrenceKey - ? [`recurrence:${learning.recurrenceKey}`] + `learning-source:${learningData.source}`, + `learning-area:${learningData.area}`, + `promotion:${learningData.promotionStatus}`, + ...(learningData.recurrenceKey + ? [`recurrence:${learningData.recurrenceKey}`] : []), ], }); diff --git a/tests/core/trajectory.test.ts b/tests/core/trajectory.test.ts index 9978739..c100b28 100644 --- a/tests/core/trajectory.test.ts +++ b/tests/core/trajectory.test.ts @@ -344,6 +344,24 @@ describe("Trajectory", () => { expect(updated).not.toHaveProperty("instructions"); }); + it("should persist only schema-validated learning fields", async () => { + const { addLearning, createTrajectory } = await import( + "../../src/core/trajectory.js" + ); + const trajectory = createTrajectory({ title: "Test task" }); + + const updated = addLearning(trajectory, { + summary: "Keep auth validation at the API boundary", + source: "code-review", + area: "src/auth", + promotionStatus: "archived", + unexpected: "must not be persisted", + } as never); + + const event = updated.chapters[0].events[0]; + expect(event.raw).not.toHaveProperty("unexpected"); + }); + it("should reject an unsupported promotion status", async () => { const { addLearning, createTrajectory } = await import( "../../src/core/trajectory.js" From 8ba175bfb9ea3c585fc76fb59960646dacd291d0 Mon Sep 17 00:00:00 2001 From: SuhaibAslam Date: Mon, 3 Aug 2026 15:56:07 +0200 Subject: [PATCH 3/3] test: strengthen learning validation assertions --- tests/core/trajectory.test.ts | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/tests/core/trajectory.test.ts b/tests/core/trajectory.test.ts index c100b28..eadfa27 100644 --- a/tests/core/trajectory.test.ts +++ b/tests/core/trajectory.test.ts @@ -337,7 +337,11 @@ describe("Trajectory", () => { const event = updated.chapters[0].events[0]; expect(event.type).toBe("learning"); expect(event.significance).toBe("high"); - expect(event.raw).toMatchObject({ + expect(event.raw).toEqual({ + summary: "Keep auth validation at the API boundary", + source: "code-review", + area: "src/auth", + evidence: "PR review #42", promotionStatus: "pending_review", recurrenceKey: "auth-validation-boundary", }); @@ -359,23 +363,33 @@ describe("Trajectory", () => { } as never); const event = updated.chapters[0].events[0]; + expect(event.raw).toEqual({ + summary: "Keep auth validation at the API boundary", + source: "code-review", + area: "src/auth", + promotionStatus: "archived", + }); expect(event.raw).not.toHaveProperty("unexpected"); }); it("should reject an unsupported promotion status", async () => { - const { addLearning, createTrajectory } = await import( + const { addLearning, createTrajectory, TrajectoryError } = await import( "../../src/core/trajectory.js" ); const trajectory = createTrajectory({ title: "Test task" }); - expect(() => + try { addLearning(trajectory, { summary: "Update durable instructions immediately", source: "other", area: "AGENTS.md", promotionStatus: "approved", - } as never), - ).toThrow(); + } as never); + expect.fail("Expected unsupported promotion status to be rejected"); + } catch (error) { + expect(error).toBeInstanceOf(TrajectoryError); + expect(error).toMatchObject({ code: "VALIDATION_ERROR" }); + } }); });