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
2 changes: 2 additions & 0 deletions fixtures/working-memory-kit/SOURCE.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
decisionLog.md is a byte-identical copy of template/_working-memory/decisionLog.md from kendrick/working-memory-kit at commit 19a9a80a3c021b6b105bfa155b60fd60c3fb8ac2.
Refresh it from a newer commit and update this SHA together; test/cli/promote.test.ts promotes against it.
17 changes: 17 additions & 0 deletions fixtures/working-memory-kit/decisionLog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Decision Log

Append-only; newest entry on top. Don't edit past entries; supersede them with a new one.

Each entry follows this shape:

```markdown
## 2026-04-19: Short title

**Source:** the commit, PR, or discussion it came from (optional for hand-written entries)

**Context:** Why this came up.
**Decision:** What was decided.
**Alternatives considered:** What was rejected, and why.
```

_No decisions logged yet._
18 changes: 14 additions & 4 deletions guide/promotion.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,14 +61,24 @@ A working-memory-kit decision log holds many decisions, each under its own `## `
ltm promote _working-memory/decisionLog.md --entry "Cap retries at three per request" --bank ../acme-bank --dry-run
```

The heading becomes the title, and the text under it becomes the body. `promoted_from` adds an anchor for the entry, such as `acme/order-service:_working-memory/decisionLog.md#cap-retries-at-three-per-request`. Without `--dry-run`, the stamp goes on its own line just below the heading:
The heading becomes the title, and the text under it becomes the body. `promoted_from` adds an anchor for the entry, such as `acme/order-service:_working-memory/decisionLog.md#cap-retries-at-three-per-request`.

Lines straight under the heading can form a metadata block. The block is the run of lines that each start with `status`, `date`, `superseded_by`, or `promoted_to` followed by a colon, and it ends at the first line that doesn't. Each line takes one of three forms: `key: value`, `- key: value`, or `**key:** value`. No other key counts, so a line like `owner: x` ends the block and stays in the body.

The block never reaches the Proposal, so the body starts at the first line after it. Without `--dry-run`, the stamp goes in as the block's last line, and the blank line between the block and the prose stays where it was. An entry with no block gets the stamp directly under the heading.

```markdown
## Cap retries at three per request
## 2026-10-01: Cap retries at three per request
status: accepted
date: 2026-10-01
promoted_to: acme:Hs7kD2pVn5

Outbound calls retry at most three times per request…
```

The working-memory-kit template dates each heading, as in `## 2026-10-01: Cap retries at three per request`. When a heading opens with a calendar date and `: `, `--entry` also takes the text after the date. The title leaves the date out, and the anchor keeps it: `#2026-10-01-cap-retries-at-three-per-request`. A heading that matches exactly always wins, so an undated `## Cap retries` stays reachable beside a dated one with the same title.
A `promoted_to` counts as a stamp only inside the block. The same line in the prose, below the blank line, is ordinary text and doesn't stop a Promotion.

A heading of the form `## YYYY-MM-DD: Title`, with a colon after a calendar date, is the dated form that `--entry` can match by its bare title, as in `--entry "Cap retries at three per request"`. The title leaves the date out, and the anchor keeps it: `#2026-10-01-cap-retries-at-three-per-request`. Any other heading matches only verbatim. A heading that matches exactly always wins, so an undated `## Cap retries` stays reachable beside a dated one with the same title.

`ltm promote` may print a `body-sections` warning, because a short entry has no Context or Consequences heading. The Proposal still opens. A reviewer can ask for those sections before accepting it.

Expand All @@ -81,7 +91,7 @@ git add docs/adr/0004-retry-budget.md
git commit -m "Promote the retry budget ADR to the acme Bank"
```

The stamp tells the next reader that the decision now lives in the Bank too. It also stops a second Promotion: `ltm promote` refuses a file or entry that already has a `promoted_to`.
The stamp tells the next reader that the decision now lives in the Bank too. It also stops a second Promotion: `ltm promote` refuses a file that already has a `promoted_to`, and an entry that has one in its metadata block.

If the Proposal opened but the stamp couldn't be written, `ltm promote` prints the exact line to add by hand. Add it, then commit it the same way.

Expand Down
20 changes: 13 additions & 7 deletions src/core/promote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,9 @@ function datedTitle(text: string): string | null {
return m && isCalendarDate(m[1]!) ? m[2]! : null;
}

// Metadata block shared with working-memory-kit: key lines straight under the heading. The key set is closed, so `owner: x` stays body text. Plain, list and bold forms all count, so a styled stamp can't end the block before it's seen. Plain and list forms don't need a space after the colon, so a hand-typed `promoted_to:acme:x` still blocks a second Promotion.
const META_LINE = /^(?:(?:- )?(status|date|superseded_by|promoted_to):|\*\*(status|date|superseded_by|promoted_to):\*\* )/;

export function readEntry(rel: string, text: string, heading: string): PromoteSource | { refusal: string } {
const lines = scanLines(text);
const headings = outsideFences(lines).filter((l) => l.content.startsWith("## "));
Expand All @@ -116,21 +119,24 @@ export function readEntry(rel: string, text: string, heading: string): PromoteSo
const match = matches[0]!;
const end = headings.find((l) => l.start > match.start)?.start ?? text.length;
const fullHeading = match.content.slice(3);
const body = text.slice(match.next, end);
// Fence state comes from the whole file, so a fenced yaml sample in the entry never reads as a stamp.
const stamped = outsideFences(lines).find((l) => l.start >= match.next && l.start < end && l.content.startsWith("promoted_to:"));
// A fence opener doesn't match META_LINE, so a fenced yaml sample under the heading gives an empty block and its promoted_to is ignored.
const block: Line[] = [];
for (let i = lines.indexOf(match) + 1; i < lines.length && lines[i]!.start < end && META_LINE.test(lines[i]!.content); i++) block.push(lines[i]!);
const last = block.at(-1) ?? match;
const body = text.slice(last.next, end);
const stampLine = block.find((l) => (META_LINE.exec(l.content)![1] ?? META_LINE.exec(l.content)![2]) === "promoted_to");
const eol = eolOf(text);
const lineNo = lines.indexOf(match) + 2;
const lineNo = lines.indexOf(last) + 2;
return {
kind: "entry",
// Slug and title come from the matched heading, so both spellings stamp the same anchor.
slug: entrySlug(fullHeading),
title: datedTitle(fullHeading) ?? fullHeading,
body,
existing: stamped ? stamped.content.slice("promoted_to:".length).trim() : null,
// A heading on the last line has no terminator to insert after, so the stamp brings its own leading one and the file keeps its missing final newline.
existing: stampLine ? stampLine.content.slice(META_LINE.exec(stampLine.content)![0].length).trim() : null,
// The last line of the file has no terminator to insert after, so the stamp brings its own leading one and the file keeps its missing final newline.
stamp: (line) => ({
text: match.newline ? text.slice(0, match.next) + line + eol + text.slice(match.next) : text + eol + line,
text: last.newline ? text.slice(0, last.next) + line + eol + text.slice(last.next) : text + eol + line,
line: lineNo,
}),
};
Expand Down
31 changes: 31 additions & 0 deletions test/cli/promote.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,37 @@ describe("ltm promote", () => {
});
}, GIT_TEST_TIMEOUT);

test("--entry promotes from the pinned working-memory-kit template", async () => {
const template = readFileSync(join(import.meta.dir, "../../fixtures/working-memory-kit/decisionLog.md"), "utf8");
expect(template).toContain("_No decisions logged yet._");
await withConsumerRepo(async (repo) => {
await withGitBank(async ({ bare, clone }) => {
const log = "_working-memory/decisionLog.md";
// The template's only example sits in a fence, so it must not count as an entry.
writeFileSync(join(repo, log), template);
const refused = await ltm(repo, "promote", log, "--entry", "Short title", "--bank", clone);
expect(refused.exitCode).toBe(1);
expect(refused.stderr).toContain("has no '## Short title' heading");

const entry =
"## 2026-10-01: Cap retries at three per request\nstatus: accepted\ndate: 2026-10-01\n\n" +
"**Context:** Providers buckled under retry storms.\n**Decision:** Retry at most three times per request.\n**Alternatives considered:** Unbounded retries; rejected.";
writeFileSync(join(repo, log), template.replace("_No decisions logged yet._", entry));
const r = await ltm(repo, "promote", log, "--entry", "Cap retries at three per request", "--bank", clone, "--json");
expect(r.exitCode).toBe(0);
const out = JSON.parse(r.stdout);
const record = git(bare, "show", `${out.branch}:${out.path}`);
const closing = record.indexOf("\n---\n", 4);
const body = record.slice(closing + "\n---\n".length);
expect(record).toContain("**Decision:** Retry at most three times per request.");
expect(record).toContain(`promoted_from: acme/order-service:${log}#2026-10-01-cap-retries-at-three-per-request`);
expect(body).not.toContain("status: accepted");
expect(body).not.toContain("date: 2026-10-01");
expect(readFileSync(join(repo, log), "utf8")).toContain(`date: 2026-10-01\npromoted_to: acme:${out.id}\n\n**Context:**`);
});
});
}, GIT_TEST_TIMEOUT);

test("--json carries the propose fields plus stamped.path and stamped.line", async () => {
await withConsumerRepo(async (repo) => {
await withGitBank(async ({ clone }) => {
Expand Down
61 changes: 61 additions & 0 deletions test/core/promote.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,12 @@ describe("readEntry", () => {
expect(source(readEntry("log.md", "# Log\n\n## A\n\nText.\n\n## B\npromoted_to: acme:b\n", "A")).existing).toBeNull();
});

test("a promoted_to line with no space after the colon, or no value, still gives existing", () => {
expect(source(readEntry("log.md", "# Log\n\n## A\npromoted_to:acme:x\n\nText.\n", "A")).existing).toBe("acme:x");
expect(source(readEntry("log.md", "# Log\n\n## A\npromoted_to:\n\nText.\n", "A")).existing).toBe("");
expect(source(readEntry("log.md", "# Log\n\n## A\n\nowner:x\nText.\n", "A")).existing).toBeNull();
});

test("CRLF entries stamp with CRLF and match their heading without the \\r", () => {
const text = "# Log\r\n\r\n## A\r\nText.\r\n## B\r\nMore.\r\n";
const s = source(readEntry("log.md", text, "A"));
Expand Down Expand Up @@ -345,6 +351,56 @@ describe("readEntry dated headings", () => {
});
});

describe("readEntry metadata block", () => {
const META = "# Log\n\n## 2026-10-01: Cap retries\nstatus: accepted\ndate: 2026-10-01\n\nProse.\n";

test("the block stays out of the body", () => {
const s = source(readEntry("log.md", META, "Cap retries"));
expect(s.body).toBe("\nProse.\n");
expect(s.body).not.toContain("status: accepted");
expect(s.body).not.toContain("date: 2026-10-01");
expect(s.existing).toBeNull();
});

test("the stamp is the block's last line and the blank line survives", () => {
const stamped = source(readEntry("log.md", META, "Cap retries")).stamp("promoted_to: acme:abc");
expect(stamped.text).toBe("# Log\n\n## 2026-10-01: Cap retries\nstatus: accepted\ndate: 2026-10-01\npromoted_to: acme:abc\n\nProse.\n");
expect(stamped.line).toBe(6);
});

test("plain, list, and bold stamps in the block are existing", () => {
for (const l of ["promoted_to: acme:abc", "- promoted_to: acme:abc", "**promoted_to:** acme:abc", "- promoted_to: acme:abc "]) {
expect(source(readEntry("log.md", `## A\nstatus: accepted\n${l}\n\nProse.\n`, "A")).existing).toBe("acme:abc");
}
});

test("falsifier: a stamp below the blank line is prose, not a stamp", () => {
expect(source(readEntry("log.md", "## A\nstatus: accepted\n\npromoted_to: acme:abc\n", "A")).existing).toBeNull();
expect(source(readEntry("log.md", "## A\n\nText.\npromoted_to: acme:abc\n", "A")).existing).toBeNull();
});

test("falsifier: a key outside the set stays in the body", () => {
const s = source(readEntry("log.md", "## A\nowner: x\n\nProse.\n", "A"));
expect(s.body).toBe("owner: x\n\nProse.\n");
expect(source(readEntry("log.md", "## A\nstatus: accepted\nowner: x\n\nP.\n", "A")).body).toBe("owner: x\n\nP.\n");
});

test("CRLF block stamps with CRLF", () => {
const text = "## A\r\nstatus: accepted\r\n\r\nP.\r\n";
const s = source(readEntry("log.md", text, "A"));
expect(s.body).toBe("\r\nP.\r\n");
expect(s.stamp("promoted_to: acme:x").text).toBe("## A\r\nstatus: accepted\r\npromoted_to: acme:x\r\n\r\nP.\r\n");
});

test("a block ending the file with no newline stamps on a new line", () => {
const s = source(readEntry("log.md", "## A\nstatus: accepted", "A"));
expect(s.body).toBe("");
const stamped = s.stamp("promoted_to: acme:x");
expect(stamped.text).toBe("## A\nstatus: accepted\npromoted_to: acme:x");
expect(stamped.line).toBe(3);
});
});

describe("entrySlug", () => {
test("lowercases and collapses each run of other characters to one dash", () => {
expect(entrySlug("Use pnpm: v9 & up!")).toBe("use-pnpm-v9-up");
Expand Down Expand Up @@ -528,6 +584,11 @@ describe("promoteSource", () => {
const again = await promote(clone, repo, ADR_3, forge);
expect("refusal" in again && again.refusal).toBe(`${ADR_3} already has promoted_to 'acme:X'`);

const bold = "_working-memory/bold.md";
writeFileSync(join(repo, bold), "## A\n**promoted_to:** acme:abc\n\nP.\n");
const styled = await promote(clone, repo, bold, forge, { entry: "A" });
expect("refusal" in styled && styled.refusal).toBe(`${bold} already has promoted_to 'acme:abc'`);

const untitled = "docs/adr/0009-untitled.md";
writeFileSync(join(repo, untitled), "no heading here\n");
const notitle = await promote(clone, repo, untitled, forge);
Expand Down
Loading