Skip to content

Commit a3487b9

Browse files
Add reviewable memory learning proposals (#649)
Closes #641
1 parent 84ff181 commit a3487b9

19 files changed

Lines changed: 893 additions & 45 deletions

docs/memory.md

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -72,22 +72,34 @@ report data than hand-authored memory.
7272

7373
```bash
7474
npx codedecay memory-learn --input ci-failure.json
75+
npx codedecay memory-learn --input incidents/auth-outage.md
7576
npx codedecay memory-learn --input codedecay-report.json --apply --format json
7677
npx codedecay memory-learn --input .codedecay/local/product-runs/latest.json --apply
7778
```
7879

7980
Accepted inputs include:
8081

8182
- `ciFailures`: failing workflow, job, message, command, files, and areas
82-
- `pullRequests`: title, body, commit messages, changed files, checks, and areas
83+
- `pullRequests`: title, body, labels, commit messages, changed files, checks,
84+
and areas
85+
- `incidents` or `incidentMarkdowns`: structured incident/postmortem entries
86+
that become invariant and past-regression proposals
87+
- direct `.md` or `.markdown` incident/postmortem files
88+
- `incidentMarkdownFiles`: paths in a JSON input file, read relative to the
89+
input file
8390
- `reports`, `codeDecayReports`, `failOnReports`, or `blockedReports`
8491
- a single CodeDecay JSON report with `tool: "CodeDecay"` and `findings`
8592
- product verification reports with `tool: "CodeDecay"` and `targets`
8693
- `productReports`, `productVerificationReports`, or `productTargetReports`
8794

88-
The learner converts those signals into flows, commands, architecture notes,
89-
and past regressions. It infers impacted areas from file paths and text such as
90-
`auth`, `api`, `schema`, `migration`, `workflow`, or `coverage`.
95+
The learner converts those signals into reviewable proposals for flows,
96+
commands, invariants, architecture notes, and past regressions. Each proposal
97+
includes the source type/path, confidence, timestamp, and why the learning
98+
matters. The preview also shows the merged memory that would be written if
99+
`--apply` is passed.
100+
101+
It infers impacted areas from file paths, PR labels such as `area: auth`, and
102+
text such as `auth`, `api`, `schema`, `migration`, `workflow`, or `coverage`.
91103

92104
For CodeDecay report inputs, `memory-learn` keeps only actionable findings that
93105
include concrete evidence such as a file, impacted area, or recommended check.
@@ -101,7 +113,9 @@ stderr, screenshots, traces, request bodies, headers, cookies, or full URLs with
101113
query strings.
102114

103115
`memory-learn` is deterministic and local. It does not query GitHub, inspect
104-
remote CI, call a model, or write anything unless `--apply` is passed.
116+
remote CI, call a model, upload telemetry, or write anything unless `--apply`
117+
is passed. GitHub PR and CI data must be provided as local JSON input if you
118+
want CodeDecay to learn from it.
105119

106120
## File Format
107121

@@ -201,6 +215,8 @@ Recommended review workflow:
201215
`codedecay product --generate-api-tests --run-generated-api-tests --output .codedecay/local/product-runs/latest.json --format json`.
202216
- Preview learned memory with
203217
`codedecay memory-learn --input .codedecay/local/product-runs/latest.json`.
218+
- Review the `proposals` section for source, confidence, timestamp, and why
219+
each entry matters.
204220
- Re-run with `--apply` only after reviewing the preview.
205221
- Commit `.codedecay/memory.json` like source code so changes are visible in PRs.
206222

packages/cli/src/commands/memory.ts

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { readFileSync } from "node:fs";
2-
import { resolve } from "node:path";
2+
import { dirname, extname, resolve } from "node:path";
33
import {
44
importCodeDecayMemory,
55
learnCodeDecayMemory,
@@ -79,7 +79,7 @@ export function runMemoryLearnCommand(context: CliCommandContext, dependencies:
7979
const rootDir = dependencies.resolveRepoRoot(cwd, { format: "markdown" });
8080
const loadedMemory = loadCodeDecayMemory(rootDir);
8181
const inputPath = resolve(context.runtimeCwd, options.input);
82-
const rawLearning = JSON.parse(readFileSync(inputPath, "utf8"));
82+
const rawLearning = parseMemoryLearningInput(inputPath);
8383
const learned = learnCodeDecayMemory(loadedMemory.memory, rawLearning, inputPath);
8484
const writtenPath = options.apply ? writeCodeDecayMemory(rootDir, learned.memory) : undefined;
8585

@@ -93,3 +93,60 @@ export function runMemoryLearnCommand(context: CliCommandContext, dependencies:
9393
})
9494
);
9595
}
96+
97+
function parseMemoryLearningInput(inputPath: string): unknown {
98+
const raw = readFileSync(inputPath, "utf8");
99+
if (isMarkdownPath(inputPath)) {
100+
return {
101+
incidentMarkdowns: [
102+
{
103+
path: inputPath,
104+
markdown: raw
105+
}
106+
]
107+
};
108+
}
109+
110+
let parsed: unknown;
111+
try {
112+
parsed = JSON.parse(raw);
113+
} catch (error: unknown) {
114+
const message = error instanceof Error ? error.message : String(error);
115+
throw new Error(`Invalid memory-learn input at ${inputPath}: ${message}`);
116+
}
117+
118+
return expandIncidentMarkdownFiles(parsed, inputPath);
119+
}
120+
121+
function expandIncidentMarkdownFiles(value: unknown, inputPath: string): unknown {
122+
if (!value || typeof value !== "object" || Array.isArray(value)) {
123+
return value;
124+
}
125+
126+
const object = value as Record<string, unknown>;
127+
if (!Array.isArray(object.incidentMarkdownFiles)) {
128+
return value;
129+
}
130+
131+
const incidentMarkdowns = [
132+
...(Array.isArray(object.incidentMarkdowns) ? object.incidentMarkdowns : []),
133+
...object.incidentMarkdownFiles
134+
.filter((item): item is string => typeof item === "string" && item.trim().length > 0)
135+
.map((filePath) => {
136+
const resolved = resolve(dirname(inputPath), filePath);
137+
return {
138+
path: filePath,
139+
markdown: readFileSync(resolved, "utf8")
140+
};
141+
})
142+
];
143+
144+
return {
145+
...object,
146+
incidentMarkdowns
147+
};
148+
}
149+
150+
function isMarkdownPath(inputPath: string): boolean {
151+
return [".md", ".markdown"].includes(extname(inputPath).toLowerCase());
152+
}

packages/cli/src/docs/command-docs/state.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,23 +58,25 @@ export const STATE_COMMAND_DOCS: Record<string, CommandDoc> = {
5858
},
5959
"memory-learn": {
6060
name: "memory-learn",
61-
summary: "Learn local repo memory from CI, PR, and CodeDecay report signals.",
61+
summary: "Learn local repo memory proposals from CI, PR, incident, and CodeDecay report signals.",
6262
usage: ["codedecay memory-learn --input <path> [options]"],
6363
description: [
64-
"Convert raw-ish CI failures, merged PR descriptions, commit messages, and CodeDecay fail-on reports into reviewable `.codedecay/memory.json` entries."
64+
"Convert raw-ish CI failures, merged PR descriptions, incident markdown, commit messages, and CodeDecay fail-on reports into reviewable `.codedecay/memory.json` proposals."
6565
],
6666
options: [
67-
{ flag: "--input <path>", description: "JSON file containing ciFailures, pullRequests, reports, failOnReports, or a CodeDecay report" },
67+
{ flag: "--input <path>", description: "JSON or markdown file containing ciFailures, pullRequests, incidents, reports, failOnReports, or a CodeDecay report" },
6868
{ flag: "--cwd <path>", description: "Repository working directory (default: current directory)" },
6969
{ flag: "--format <format>", description: "json or markdown preview format (default: markdown)" },
7070
{ flag: "--apply", description: "Write the learned memory file instead of only printing the preview" }
7171
],
7272
examples: [
7373
"codedecay memory-learn --input ci-failure.json",
74+
"codedecay memory-learn --input incidents/auth-outage.md",
7475
"codedecay memory-learn --input codedecay-report.json --apply"
7576
],
7677
notes: [
77-
"Learning is deterministic and local. CodeDecay does not inspect remote CI, PRs, or GitHub automatically."
78+
"Learning is deterministic and local. CodeDecay does not inspect remote CI, PRs, or GitHub automatically.",
79+
"Preview output includes proposals with source, confidence, timestamp, and why before --apply writes memory."
7880
]
7981
}
8082
};

packages/cli/src/renderers/memory.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { CODEDECAY_VERSION } from "@submuxhq/codedecay-core";
2-
import type { LoadedCodeDecayMemory, MemoryImportResult, MemoryLearnResult } from "@submuxhq/codedecay-memory";
2+
import type { LoadedCodeDecayMemory, MemoryImportResult, MemoryLearnResult, MemoryLearningProposal } from "@submuxhq/codedecay-memory";
33
import type { ConfigFormat } from "../types";
44

55
export function renderMemory(loadedMemory: LoadedCodeDecayMemory, format: ConfigFormat): string {
@@ -86,6 +86,7 @@ export function renderMemoryLearnResult(input: {
8686
learned: input.result.learned,
8787
added: input.result.added,
8888
merged: input.result.merged,
89+
proposals: input.result.proposals,
8990
memory: input.result.memory
9091
},
9192
null,
@@ -108,9 +109,40 @@ export function renderMemoryLearnResult(input: {
108109
`| Architecture notes | ${input.result.learned.architecture} | ${input.result.added.architecture} | ${input.result.merged.architecture} |`,
109110
`| Past regressions | ${input.result.learned.regressions} | ${input.result.added.regressions} | ${input.result.merged.regressions} |`,
110111
"",
112+
...renderLearningProposalsMarkdown(input.result.proposals),
111113
renderMemory({ memory: input.result.memory, sourcePath: input.writtenPath }, "markdown").trim(),
112114
""
113115
];
114116

115117
return `${lines.join("\n")}\n`;
116118
}
119+
120+
function renderLearningProposalsMarkdown(proposals: MemoryLearningProposal[]): string[] {
121+
if (proposals.length === 0) {
122+
return ["### Proposals", "", "No memory proposals were generated.", ""];
123+
}
124+
125+
return [
126+
"### Proposals",
127+
"",
128+
"| Section | Title | Confidence | Source | Why |",
129+
"| --- | --- | --- | --- | --- |",
130+
...proposals.slice(0, 20).map((proposal) =>
131+
`| ${proposal.section} | ${proposal.title} | ${proposal.confidence} | ${formatProposalSource(proposal)} | ${proposal.why} |`
132+
),
133+
proposals.length > 20 ? `| ... | ${proposals.length - 20} more proposal(s) omitted from markdown | | | |` : undefined,
134+
""
135+
].filter((line): line is string => line !== undefined);
136+
}
137+
138+
function formatProposalSource(proposal: MemoryLearningProposal): string {
139+
const parts = [
140+
proposal.source.type,
141+
proposal.source.title ? `title: ${proposal.source.title}` : undefined,
142+
proposal.source.id ? `id: ${proposal.source.id}` : undefined,
143+
proposal.source.labels && proposal.source.labels.length > 0 ? `labels: ${proposal.source.labels.join(", ")}` : undefined,
144+
`path: ${proposal.source.path}`,
145+
`timestamp: ${proposal.timestamp}`
146+
].filter((item): item is string => item !== undefined);
147+
return parts.join("<br>");
148+
}

packages/cli/test/memory.test.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,119 @@ describe("codedecay memory CLI contract", () => {
258258
expect.objectContaining({ title: "CodeDecay: Risky source changes without changed tests" })
259259
])
260260
);
261+
expect(parsed.proposals).toEqual(
262+
expect.arrayContaining([
263+
expect.objectContaining({
264+
section: "regressions",
265+
title: "Auth smoke failed",
266+
source: expect.objectContaining({ type: "ci-failure" }),
267+
confidence: "high",
268+
why: expect.stringContaining("CI failure")
269+
})
270+
])
271+
);
272+
});
273+
274+
it("learns reviewable memory proposals from incident markdown without applying by default", async () => {
275+
const repo = createLowRiskRepo();
276+
const inputPath = join(repo, "auth-incident.md");
277+
writeFile(
278+
repo,
279+
"auth-incident.md",
280+
[
281+
"# Auth cache outage",
282+
"",
283+
"Incident: stale auth cache allowed a forbidden session after deploy.",
284+
"Prevention: auth cache invalidation must be verified after session changes."
285+
].join("\n")
286+
);
287+
288+
const preview = await run(["memory-learn", "--input", inputPath, "--format", "json"], repo);
289+
const previewJson = JSON.parse(preview.stdout);
290+
291+
expect(preview.exitCode).toBe(0);
292+
expect(previewJson.writtenPath).toBeUndefined();
293+
expect(previewJson.memory.invariants).toEqual(
294+
expect.arrayContaining([expect.objectContaining({ name: "Auth cache outage", severity: "high" })])
295+
);
296+
expect(previewJson.proposals).toEqual(
297+
expect.arrayContaining([
298+
expect.objectContaining({
299+
section: "invariants",
300+
title: "Auth cache outage",
301+
source: expect.objectContaining({
302+
type: "incident-markdown",
303+
path: inputPath
304+
}),
305+
confidence: "high",
306+
why: expect.stringContaining("durable rule")
307+
})
308+
])
309+
);
310+
expect(existsSync(join(repo, ".codedecay/memory.json"))).toBe(false);
311+
312+
const applied = await run(["memory-learn", "--input", inputPath, "--apply", "--format", "json"], repo);
313+
const appliedJson = JSON.parse(applied.stdout);
314+
315+
expect(applied.exitCode).toBe(0);
316+
expect(appliedJson.writtenPath).toContain(".codedecay/memory.json");
317+
expect(JSON.parse(readFileSync(join(repo, ".codedecay/memory.json"), "utf8")).invariants).toEqual(
318+
expect.arrayContaining([expect.objectContaining({ name: "Auth cache outage" })])
319+
);
320+
});
321+
322+
it("reports malformed memory-learn inputs without crashing", async () => {
323+
const repo = createLowRiskRepo();
324+
const inputPath = join(repo, "broken-learn.json");
325+
writeFile(repo, "broken-learn.json", "{");
326+
327+
const result = await run(["memory-learn", "--input", inputPath, "--format", "json"], repo);
328+
329+
expect(result.exitCode).toBe(2);
330+
expect(result.stdout).toBe("");
331+
expect(result.stderr).toContain("Invalid memory-learn input");
332+
});
333+
334+
it("matches learned past-regression memory in redteam reports after explicit apply", async () => {
335+
const repo = createRepo({
336+
"src/auth/session.ts": "export function session() { return { ok: true }; }\n"
337+
});
338+
const inputPath = join(repo, "memory-learn.json");
339+
writeFile(
340+
repo,
341+
"memory-learn.json",
342+
JSON.stringify(
343+
{
344+
ciFailures: [
345+
{
346+
title: "Auth smoke failed",
347+
message: "Token refresh returned 401 after deploy.",
348+
command: "pnpm test auth",
349+
files: ["src/auth/session.ts"]
350+
}
351+
]
352+
},
353+
null,
354+
2
355+
)
356+
);
357+
358+
const applied = await run(["memory-learn", "--input", inputPath, "--apply", "--format", "json"], repo);
359+
expect(applied.exitCode).toBe(0);
360+
361+
writeFile(repo, "src/auth/session.ts", "export function session() { return { ok: false }; }\n");
362+
const redteam = await run(["redteam", "--format", "json"], repo);
363+
const report = JSON.parse(redteam.stdout);
364+
365+
expect(redteam.exitCode).toBe(0);
366+
expect(report.analysis.findings).toEqual(
367+
expect.arrayContaining([
368+
expect.objectContaining({
369+
ruleId: "memory-past-regression-area",
370+
file: "src/auth/session.ts"
371+
})
372+
])
373+
);
261374
});
262375

263376
it("learns memory from product verification reports", async () => {

packages/memory/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@ export type {
2424
MemoryImportCounts,
2525
MemoryImportResult,
2626
MemoryInvariant,
27+
MemoryLearningProposal,
28+
MemoryLearningProposalConfidence,
29+
MemoryLearningProposalSection,
30+
MemoryLearningProposalSource,
31+
MemoryLearningSourceType,
2732
MemoryLearnResult,
2833
MemoryMatcher,
2934
MemoryProvider,

packages/memory/src/learn-memory.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,24 @@ import {
22
countMemoryEntries,
33
importCodeDecayMemory
44
} from "./import-memory";
5-
import { normalizeLearnedMemory } from "./learn-memory/normalize";
5+
import { normalizeLearnedMemoryWithProposals } from "./learn-memory/normalize";
6+
import { createMemoryLearningContext, finalizeMemoryProposals } from "./learn-memory/proposals";
67
import type { CodeDecayMemory, MemoryLearnResult } from "./types";
78

89
export function learnCodeDecayMemory(
910
baseMemory: CodeDecayMemory,
1011
learnedValue: unknown,
11-
sourceName: string = "memory learn"
12+
sourceName: string = "memory learn",
13+
options: { timestamp?: string | undefined } = {}
1214
): MemoryLearnResult {
13-
const learnedMemory = normalizeLearnedMemory(learnedValue, sourceName);
15+
const context = createMemoryLearningContext(sourceName, options.timestamp ?? new Date().toISOString());
16+
const learned = normalizeLearnedMemoryWithProposals(learnedValue, sourceName, context);
17+
const learnedMemory = learned.memory;
1418
const result = importCodeDecayMemory(baseMemory, learnedMemory, sourceName);
1519

1620
return {
1721
...result,
18-
learned: countMemoryEntries(learnedMemory)
22+
learned: countMemoryEntries(learnedMemory),
23+
proposals: finalizeMemoryProposals(learned.proposals)
1924
};
2025
}

0 commit comments

Comments
 (0)