From 1ae63a8e2672c43a296b2a91670234bace10e4ab Mon Sep 17 00:00:00 2001 From: Kendrick Arnett Date: Tue, 10 Nov 2026 15:01:00 -0600 Subject: [PATCH 1/4] fix(promote): keep a decision-log entry's metadata block out of the Proposal working-memory-kit plans to put `status:`, `date:` and `superseded_by:` lines directly under each entry heading. `readEntry` copied the whole section into the Proposal body, so those lines would land in the Bank. It also took any `promoted_to:` line anywhere in the section as the stamp, which missed `- promoted_to:` and `**promoted_to:**` and refused an entry whose prose happened to start a line with the key. `readEntry` now reads the run of lines under the heading whose key is one of `status`, `date`, `superseded_by` or `promoted_to`, in plain, list or bold form. That block stays out of the body, a stamp counts only inside it, and the new stamp goes in as the block's last line so the blank line before the prose survives. The key set is closed, so `owner: x` stays body text. An entry with no block reads and stamps as before. Refs #176. --- src/core/promote.ts | 20 +++++++++----- test/core/promote.test.ts | 55 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 7 deletions(-) diff --git a/src/core/promote.ts b/src/core/promote.ts index 939517c..2b65243 100644 --- a/src/core/promote.ts +++ b/src/core/promote.ts @@ -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: consecutive 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. +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("## ")); @@ -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 isn't a block line, so a fenced yaml sample under the heading yields an empty block and its promoted_to stays 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, }), }; diff --git a/test/core/promote.test.ts b/test/core/promote.test.ts index e0308b1..051da99 100644 --- a/test/core/promote.test.ts +++ b/test/core/promote.test.ts @@ -345,6 +345,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"); @@ -528,6 +578,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); From 18d3f3cc1125c51a9d4915f625fbb2f8748d4f72 Mon Sep 17 00:00:00 2001 From: Kendrick Arnett Date: Tue, 10 Nov 2026 15:16:19 -0600 Subject: [PATCH 2/4] test(promote): promote an entry from a pinned working-memory-kit decisionLog template Nothing tested `ltm promote --entry` against the log working-memory-kit ships, only a hand-written fixture. `fixtures/working-memory-kit/decisionLog.md` is a byte-identical copy of its `template/_working-memory/decisionLog.md`, with the source commit in `SOURCE.txt` beside it so a refresh can be checked with a plain diff. The CLI test confirms the template's fenced example isn't an entry, then fills in an entry with a metadata block and checks that the block stays out of the Record body and the stamp lands after `date:`. With the old `readEntry` the test fails on `status: accepted` in the body. Refs #176. --- fixtures/working-memory-kit/SOURCE.txt | 2 ++ fixtures/working-memory-kit/decisionLog.md | 17 ++++++++++++ test/cli/promote.test.ts | 31 ++++++++++++++++++++++ 3 files changed, 50 insertions(+) create mode 100644 fixtures/working-memory-kit/SOURCE.txt create mode 100644 fixtures/working-memory-kit/decisionLog.md diff --git a/fixtures/working-memory-kit/SOURCE.txt b/fixtures/working-memory-kit/SOURCE.txt new file mode 100644 index 0000000..da325c9 --- /dev/null +++ b/fixtures/working-memory-kit/SOURCE.txt @@ -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. diff --git a/fixtures/working-memory-kit/decisionLog.md b/fixtures/working-memory-kit/decisionLog.md new file mode 100644 index 0000000..42419d9 --- /dev/null +++ b/fixtures/working-memory-kit/decisionLog.md @@ -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._ diff --git a/test/cli/promote.test.ts b/test/cli/promote.test.ts index 1ed4517..1f1def5 100644 --- a/test/cli/promote.test.ts +++ b/test/cli/promote.test.ts @@ -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 }) => { From 01a48d06cd5d4dd92d6b429251069b9bde663409 Mon Sep 17 00:00:00 2001 From: Kendrick Arnett Date: Tue, 10 Nov 2026 15:16:19 -0600 Subject: [PATCH 3/4] docs(promotion): document the decision-log metadata block and stamp position The guide said the stamp goes right under the heading and that any `promoted_to` refuses an entry, which stopped being true with the metadata block. It now names the four keys and three line forms, says the block stays out of the Proposal, and shows the stamp as the block's last line. It also says which headings `--entry` matches by bare title. Refs #176. --- guide/promotion.md | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/guide/promotion.md b/guide/promotion.md index d3e377e..fbf930b 100644 --- a/guide/promotion.md +++ b/guide/promotion.md @@ -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. @@ -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. From 5ac0a8f5999b5156679fb88ca1861e410eb79cde Mon Sep 17 00:00:00 2001 From: Kendrick Arnett Date: Tue, 10 Nov 2026 15:38:50 -0600 Subject: [PATCH 4/4] fix(promote): still refuse a stamp written without a space after the colon The metadata-block rewrite made META_LINE require `: ` after the key. Before it, readEntry found the stamp with `startsWith("promoted_to:")`, so a hand-typed `promoted_to:acme:x` under the heading blocked a second Promotion. The plain and list forms now accept the key with or without a space, and a new readEntry test covers the no-space and bare-key cases. Also rewords the META_LINE and fence comments. --- src/core/promote.ts | 6 +++--- test/core/promote.test.ts | 6 ++++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/core/promote.ts b/src/core/promote.ts index 2b65243..bb69ba2 100644 --- a/src/core/promote.ts +++ b/src/core/promote.ts @@ -105,8 +105,8 @@ function datedTitle(text: string): string | null { return m && isCalendarDate(m[1]!) ? m[2]! : null; } -// Metadata block shared with working-memory-kit: consecutive 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. -const META_LINE = /^(?:(?:- )?(status|date|superseded_by|promoted_to): |\*\*(status|date|superseded_by|promoted_to):\*\* )/; +// 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); @@ -119,7 +119,7 @@ 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); - // A fence opener isn't a block line, so a fenced yaml sample under the heading yields an empty block and its promoted_to stays ignored. + // 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; diff --git a/test/core/promote.test.ts b/test/core/promote.test.ts index 051da99..eb45569 100644 --- a/test/core/promote.test.ts +++ b/test/core/promote.test.ts @@ -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"));