Keeps a project's memory in its .claude folder: decisions recorded as ADRs, technical debt tracked in a ledger, and a handoff that carries each session's state…

Why use it • What's inside • Install • Usage • Compatibility • FAQ
[!TIP] TL;DR — Make a decision, or find a problem you cannot fix yet, and Claude writes it down in your project's
.claudefolder: an ADR for the decision, a debt entry for the problem. Type/project-maintainer:write-handoffbefore you stop, and your next session starts with the whole story, including every debt item still open. Claude cannot quietly rewrite a decision you already settled: it asks you first.
Months later, nobody remembers why the project uses one database over another, or that the export job still breaks on large files. A new session starts with an empty conversation. The answers live in chats that are gone.
.claude folder.The two skills teach Claude how to keep your project's memory. The hook and the mod make sure that memory stays reliable even when Claude forgets an instruction or makes a mistake: the important rules are checked in code, not left to the model.
| Component | Name | What it does for you | What it prevents | How it runs |
|---|---|---|---|---|
| 🎯 Skill | project-maintainer:write-records | Writes decisions as ADRs from a template and keeps a pending and a closed debt ledger. It searches for an existing record first, asks only for facts it is missing, and tells you in one line what it saved and where | Decisions and postponed problems that live only in a chat that is gone | Automatically when a decision is made or a problem is postponed, or with /project-maintainer:write-records |
| 🎯 Skill | project-maintainer:write-handoff | Reads the project's state, summarizes the session, compares it with the previous handoff and with your records, drafts the new handoff with you, saves it in .claude/session-handoff/ and offers to commit and push it | Ending a session with no record of what was done, decided or left open | /project-maintainer:write-handoff, or ask Claude to write a handoff when you wrap up |
| 🪝 Hook | SessionStart | Shows you a one-line notice naming the newest handoff, has Claude read it in full, do what you ask and end its reply with the open items, and says which records changed after the handoff was written | A new session that starts from zero, or works from a handoff the records have since outdated | Automatically, at the start of a session and after /clear |
| 🪝 Mod | Format guard | Checks every save of an ADR or a debt ledger against the format and refuses a broken one, telling Claude what to fix | A malformed record: a missing field, a repeated debt ID, a closed entry without an outcome, a link to a record that does not exist | Automatically, on every save. Nobody can waive it. See Guardrails |
| 🪝 Mod | Authority guard | Asks you before a settled record changes, and before a shell command rewrites, moves or deletes the records folders | Claude quietly rewriting history: editing a decision you accepted, changing a closed debt entry, or dropping a pending item without its closed entry | Automatically, on every save and shell command that touches the records. Only you can approve |
| 🪝 Mod | Compaction note | Tells Claude Code's summary to keep your decisions, the open items and every record ID | Losing the thread of the records when a long conversation is compacted | Automatically, before a compaction in a project with records or handoffs |
| 🪝 Mod | Commit reminder | After a commit that changes dependencies, a schema, CI or infrastructure, or whose message says workaround, temporary or revert, reminds Claude to record the decision or the debt behind it | A change that settles a decision going in with no record of why. It uses no model call | Automatically, after such a commit. You can turn it off |
The mod runs in every Claude Code session that loads the plugin: the terminal (JetBrains included), the Desktop app's Code tab, the VS Code extension, claude -p, the Agent SDK, Remote Control and cloud sessions that receive the plugin, but not a Desktop session on WSL. The hook also runs in Cowork sessions. Where the mod does not run, the rules of the records are instructions Claude follows, not checks. See Compatibility.
The ADR template follows MADR 4.0 and adds a scope, a risks table and a confirmation table.
| Requirement | Details |
|---|---|
| Claude Code | v2.1.307 or later — check with claude --version. The guardrails need it: from that version, Claude Code puts their prompts to you and no classifier can answer them. On an older version, auto mode's classifier can approve the edit for you |
| Surfaces | See Compatibility |
| External tools | bash on PATH, for the hook and the skills' commands; on Windows, Git for Windows. ls, grep and date, which macOS and Linux include, to search and number records. git, so the handoff can record the repository's state; outside a git repository the handoff says so and skips it. gh signed in, optional, to list the repository's open pull requests |
| Network | Records: none. The handoff runs git fetch to compare your branch with its remote, and gh pr list to list pull requests, when the repository has a remote. Without network access it marks those lines as not verified |
| Accounts / API keys | None. git and gh use the credentials you already have |
# One step: adds the marketplace (if needed) and installs the plugin — Claude Code v2.1.307+
claude plugin install project-maintainer --marketplace nerymurillohnd/the-claude-factory
# Or, with the marketplace already added
claude plugin install project-maintainer@the-claude-factory
Add --scope project to enable it for everyone working in the current repository; each collaborator still installs it once on their own machine.
/plugin install project-maintainer --marketplace nerymurillohnd/the-claude-factory
Requires Claude Code v2.1.307+. With the marketplace already added, /plugin install project-maintainer@the-claude-factory opens the plugin's details so you can pick a scope. You can also browse it in /plugin → Discover.
nerymurillohnd/the-claude-factory.In the desktop app's Code tab you can also click + next to the prompt box → Add plugins (or Plugins → Browse plugins), or open Settings → Plugins → Discover.
[!NOTE] New to this marketplace? The installation guide covers every surface step by step. Used the Session Handoff plugin? This plugin replaces it, and the marketplace renames an existing install for you. Its handoffs in
.claude/session-handoff/keep working. If you installed Session Handoff from another source, turn it off, or you get the start-of-session reminder twice.
We decided to keep sessions in Postgres instead of Redis. Record it.
Result: Claude searches your existing records, asks only for what it is missing (for example, why Redis was rejected), and saves .claude/maintainer/decisions/ADR_2026-03-14_keep-sessions-in-postgres.md with the context, the options, the outcome and its consequences. It then tells you the file name in one line. The record is accepted when you made the decision in the conversation and proposed when it is still open.
Result: Claude writes one ADR for each decision, so each one can be replaced on its own later.
The nightly export fails on files over 2 GB. We cannot fix it until the vendor ships the new API. Log it as debt.
Result: Claude searches both ledgers for the same problem, then adds DEBT-001 to .claude/maintainer/technical-debt/pending-debt.md, creating the ledger the first time. The entry separates confirmed facts from inferences and records the impact, why it is not fixed now, the next action and the event that lets you close it.
DEBT-001 is fixed.
Result: Claude runs two checks itself: that the fix works and that the original failure no longer reproduces. With both passing, it moves the entry to closed-debt.md under the same ID, with the outcome resolved and the evidence. If either check fails, the item stays open and its entry is updated.
We removed the export job, so DEBT-001 no longer applies.
Result: Claude checks that the condition is gone and closes the item as obsolete, saying what changed (a decision, a commit or a pull request). It does not pretend a fix happened.
We will live with DEBT-001 for good.
Result: Claude records that decision as an ADR (or links the one that exists) and closes the item as accepted. A risk you keep for now but still review stays open with the status Risk accepted.
We are moving sessions back to Redis. Update the record.
Result: Claude writes a new ADR that supersedes the old one, then marks the old one superseded and links the two. The reasoning of the earlier record is not touched. A record still marked proposed is not settled, so Claude edits it in place. A typo in a settled record needs your approval.
/project-maintainer:write-handoff
Result: Claude checks the project's state (branch, uncommitted work, stashes, unpushed commits, open pull requests), summarizes the session from its start and compares it with the previous handoff. Then it reconciles the handoff with your records: every pending debt item is listed in the open items with its first action, debt closed since the last handoff is closed there too, whatever its outcome, and the decisions you recorded since then are added. It shows you the complete proposal in six sections: where the project stands, the open items in priority order, the context the next session needs, the decisions, what was done and what could not be verified. Once you approve it, the handoff is saved as .claude/session-handoff/2026-03-14-1730.md (the date and time in UTC). In a git repository, Claude asks whether to commit and push it. The handoff marks the end of the session.
Start Claude Code in the same project, at its root or in any subfolder of the repository, and type your first request as usual.
Result: Claude reads the newest handoff in full and does what you asked. If your request differs from the pending work, it does your request anyway. It works with the handoff's decisions, corrections and learnings as background, so past mistakes are not repeated. At the end of its reply it lists the open items, debt included, one short line each, so you can decide what to do next. It opens a debt entry only when it needs the detail. If the records changed after the handoff was written (a new debt item, a closed one, a newer ADR), the session starts with one extra line that names them.
/clear
Result: the conversation is emptied and the newest handoff is read again, as at the start of a session. After /resume the handoff is still in the conversation. After a compaction only a summary of it remains: ask Claude to reread the newest handoff if you need the details.
Run /project-maintainer:write-handoff again at the end of the next session.
Result: every open item of the previous handoff gets a verdict (done with its evidence, partial, still open, or dropped by you), and the open ones are carried into the new handoff with their original date. Only items that are still pending, in part or in full, are carried. Claude asks you about the ones that look obsolete or outside the project, and only you can drop an item. A result you report from outside the session, such as a test you ran in another app, closes an item as done, noted as reported by you. Items open for more than 30 days are marked for you to confirm or drop. If a record contradicts the previous handoff, the record wins and the handoff lists the conflict.
The first time Claude is about to create a records folder, it looks through your project for documents that already track the same thing: an ADR or decisions folder, a DECISIONS.md, a TECH_DEBT.md, a known-issues or pending list, or any path your CLAUDE.md or AGENTS.md names.
Result: Claude shows you what it found, one line per document, and asks whether to consolidate and migrate it. If you agree, each decision becomes an ADR and each postponed problem becomes a debt item, using the original wording and writing "not recorded in the source" where the original says nothing. Task lists that can be done now stay where they are. Afterwards Claude asks, for each original, whether to replace it with a one-line pointer, delete it or keep it, and it changes nothing until you answer. This way each fact lives in one place.
| What | Where |
|---|---|
| Decisions | .claude/maintainer/decisions/ADR_<YYYY-MM-DD>_<slug>.md, one file per decision |
| Pending debt | .claude/maintainer/technical-debt/pending-debt.md |
| Closed debt | .claude/maintainer/technical-debt/closed-debt.md |
| Handoffs | .claude/session-handoff/<YYYY-MM-DD-HHMM>.md, one file per handoff, never overwritten |
The files live in the .claude/ folder of your project because they are Claude's working documentation about it. The locations are fixed, so every session finds them in the same place.
The first time Claude is about to create .claude/maintainer/ in a git repository, it asks you once whether git should track it (recommended, so your team shares it) or ignore it (it stays on your machine). If you choose to ignore it, Claude adds .claude/maintainer/ to the .gitignore at the root of your checkout and never asks again. It does not ask when the folder already exists, and in a non-interactive run such as claude -p it tracks the records and says so. Handoffs follow your own git settings: the plugin never edits your .gitignore for them.
Claude Code protects the .claude/ folder, so the first record or handoff you save in a session can open a permission prompt. Reading them never asks.
| Your permission mode | What happens when Claude saves a record or a handoff |
|---|---|
| Default or accept edits | Claude Code asks. Choose Yes, and allow Claude to edit files in this project's .claude folder for this session and it stops asking until the session ends. |
| Auto | A classifier decides instead of you. |
| Bypass permissions | No prompt. |
Non-interactive (claude -p, don't ask) | Nobody can answer the prompt, so Claude shows you the record as a draft. |
[!NOTE] A rule in your
settings.json, such asEdit(.claude/**), does not remove this prompt: Claude Code checks protected folders before it reads allow rules. See Protected paths in the Claude Code docs.
The plugin ships a Claude Code mod: code that runs inside Claude Code and checks every save of a record. It turns the rules of the records from instructions Claude may forget into checks it cannot skip.
| What Claude tries to do | What happens |
|---|---|
Save an ADR or a debt ledger that breaks the format: a status outside the list, a date that differs from the file name, no decision maker, a link to a record that does not exist, a repeated or invalid DEBT-NNN, a closed entry without an outcome | Refused, with the reason. Claude fixes the record and saves it again. There is nothing for you to approve |
Rewrite a settled ADR beyond its status, its superseded-by link and its Confirmation section | Claude Code asks you |
| Change a closed debt entry beyond its "Superseded by" field | Claude Code asks you |
Remove an item from pending-debt.md while closed-debt.md has no entry with an outcome for it (for accepted, an ADR that exists) | Claude Code asks you |
| Rewrite, move or delete the records folders with a shell command | Claude Code asks you. Copying out of the folder and reading it stay quiet. Records are written with the Write and Edit tools, which apply these rules |
The prompt is Claude Code's own one for files in the .claude folder: it shows the change but not which rule asked for it. Approve only an edit you asked for. Choosing Yes, and allow Claude to edit files in this project's .claude folder for this session does not switch the guardrails off: the next change to a settled record asks you again. Where nobody can answer, as in claude -p or the dontAsk mode, the edit is refused and Claude is told to write the new record instead. In every other permission mode, auto mode included, the prompt comes to you and no classifier can approve it for you.
Everything else stays quiet: a new ADR, a new debt item, a proposed ADR that changes, a status moving from accepted to superseded or deprecated, results in the Confirmation section, and a pending item that leaves after its closed entry exists.
Two small helpers run beside the guardrails:
ADR_… and DEBT-… ID, so the records can be reconciled afterwards. It happens only in a project that has records or handoffs./plugin install, and lists it in /config. Installing from the shell never asks, so the reminder stays on: use claude plugin install project-maintainer@the-claude-factory --config commitNudge=false, or change it in /config.What the mod can reach. A mod runs with your permissions and is not sandboxed, so here is everything this one does, as claude plugin validate lists it:
| It | When |
|---|---|
Handles tool.call and tool.check on Write, Edit and Bash, and session.compact | On every such call and every compaction |
Reads, lists and checks files ($.fs.read, $.fs.list, $.fs.exists) | Only the records in .claude/maintainer/. Before a compaction it also checks whether .claude/session-handoff/ exists, without reading it |
Reads the session's folder ($.session.cwd, $.session.root) | To find your project's records |
Runs git rev-parse HEAD and git show --name-only ($.process.run) | Only around a git commit, while the commit reminder is on |
Adds one note to the conversation ($.session.append) | Only after a commit that looks like a decision |
It writes no file, makes no network request, calls no model and reads no environment variable. To check this yourself before you install, clone the repository and run claude plugin validate plugins/project-maintainer: its hooks: and calls: lines list the same.
[!NOTE] The guardrails protect your records from Claude's mistakes. They are not a security boundary: a shell command written in a form they do not recognize, or your editor, can still change the files, and you can always edit a record by hand. They run in every Claude Code session that loads the plugin except a Desktop session on WSL, and need v2.1.307 or later. They are off under
--safe-mode, and with"disableAllHooks": truein your settings. If a check itself breaks, it fails closed for the records only: a record that could not be checked is not saved and Claude is told to try again, a settled record that could not be checked asks you, and every other file is saved as usual.
claude plugin update project-maintainer@the-claude-factory
Or in a session: /plugin → Installed → Project Maintainer → Update now. Then run /reload-plugins.
Installed from the Claude app? In Claude Code (v2.1.273+, signed in with th
hooks/register.ts 202 lines1// Project Maintainer's guardrails. Every call on `$` lives in this file; the rules themselves are
2// pure functions in the sibling modules, so the tests can run them without an engine.
3//
4// - Format guard: refuses to save a malformed ADR or debt ledger, or one it could not check.
5// Nobody can waive it.
6// - Authority guard: asks the person before a settled record is rewritten. Claude cannot waive it.
7// - Compaction note: keeps decisions, open items and record IDs in the summary.
8// - Commit nudge: after a commit that looks like a decision, reminds Claude of write-records.
9
10import type { EngineInterface, On, PluginOptions } from 'claude-code'
11import {
12 askReason,
13 authorityReasons,
14 bashReason,
15 commitSignals,
16 fileInput,
17 formatProblems,
18 mayWriteRecord,
19 nudgeText,
20 refusal,
21 resultingText,
22 type Surroundings,
23 withKeepNote,
24} from './guard.ts'
25import { ADR_NAME, adrId, classify, type RecordRef, resolvePath } from './paths.ts'
26
27type Read = {
28 /** The text of the file, or null when it does not exist (or could not be read). */
29 text: string | null
30 /** True when the file exists and could not be read: nothing can be checked against it. */
31 failed: boolean
32}
33
34async function readText($: EngineInterface, path: string): Promise<Read> {
35 try {
36 if (!(await $.fs.exists(path))) {
37 return { text: null, failed: false }
38 }
39 return { text: await $.fs.read(path), failed: false }
40 } catch {
41 return { text: null, failed: true }
42 }
43}
44
45async function around($: EngineInterface, ref: RecordRef): Promise<Surroundings> {
46 const decisions =
47 ref.kind === 'adr' ? ref.dir : ref.dir.replace(/\/technical-debt$/, '/decisions')
48 let names: string[] = []
49 try {
50 names = (await $.fs.list(decisions))
51 .filter((entry) => entry.kind === 'file')
52 .map((entry) => entry.name)
53 } catch {
54 // The folder does not exist yet: there is no ADR to point at.
55 }
56 const adrIds = new Set(names.filter((name) => ADR_NAME.test(name)).map(adrId))
57 if (ref.kind === 'adr') {
58 return { otherLedger: null, adrIds }
59 }
60 const other = ref.kind === 'pending-debt' ? 'closed-debt.md' : 'pending-debt.md'
61 return { otherLedger: (await readText($, `${ref.dir}/${other}`)).text, adrIds }
62}
63
64async function headOf($: EngineInterface, cwd: string): Promise<string> {
65 try {
66 const head = await $.process.run(['git', 'rev-parse', 'HEAD'], { cwd, timeoutMs: 5000 })
67 return head.exitCode === 0 ? head.stdout.trim() : ''
68 } catch {
69 return ''
70 }
71}
72
73export function register(on: On, options: PluginOptions): void {
74 const { commitNudge } = options
75 const nudge = commitNudge !== false
76
77 // Format guard: runs before the permission check, so a malformed record never reaches a prompt.
78 on('tool.call', { tool: ['Write', 'Edit'] }, async ($, e, next) => {
79 const input = fileInput(e)
80 const path = input === null ? null : resolvePath(input.path, await $.session.cwd())
81 const ref = path === null ? null : classify(path)
82 if (input === null || path === null || ref === null) {
83 return next(e)
84 }
85 const { text: current, failed } = await readText($, path)
86 if (failed) {
87 return {
88 deny: `Project Maintainer could not read ${ref.name} to check the edit. Try the edit again.`,
89 }
90 }
91 const resulting = resultingText(input, current)
92 if (resulting === null) {
93 return next(e)
94 }
95 const problems = formatProblems(ref, current, resulting, await around($, ref))
96 return problems.length > 0 ? { deny: refusal(ref.name, problems) } : next(e)
97 }).catch(async (_$, e, next) => {
98 // Fail closed for records only: a broken check never blocks a save outside the records.
99 if (next.called || !mayWriteRecord(e)) {
100 return next(e)
101 }
102 return {
103 deny: 'Project Maintainer could not check this record against its format, so it was not saved. Try the save again, and tell the user if it fails again.',
104 }
105 })
106
107 // Authority guard: only the person can approve rewriting a settled record.
108 on('tool.check', { tool: ['Write', 'Edit'] }, async ($, e, next) => {
109 const decided = await next(e)
110 const input = decided.decision === 'deny' ? null : fileInput(e.input)
111 const path = input === null ? null : resolvePath(input.path, await $.session.cwd())
112 const ref = path === null ? null : classify(path)
113 if (input === null || path === null || ref === null) {
114 return decided
115 }
116 const { text: current, failed } = await readText($, path)
117 if (failed) {
118 return {
119 decision: 'ask',
120 reason: `Project Maintainer could not read ${ref.name} to check the edit against the rules of the records. Approve only if it leaves the records as they are.`,
121 }
122 }
123 const resulting = resultingText(input, current)
124 if (current === null || resulting === null) {
125 return decided
126 }
127 const reasons = authorityReasons(ref, current, resulting, await around($, ref))
128 return reasons.length > 0 ? { decision: 'ask', reason: askReason(ref.name, reasons) } : decided
129 }).catch(async (_$, e, next) => {
130 // Any file outside the records keeps the verdict beneath. A record asks, unless that verdict
131 // is a deny; if the verdict itself cannot be read, the record still asks.
132 if (!mayWriteRecord(e.input)) {
133 return next(e)
134 }
135 const beneath = await next(e).catch(() => null)
136 return beneath?.decision === 'deny'
137 ? beneath
138 : {
139 decision: 'ask',
140 reason:
141 'Project Maintainer could not check this edit against the rules of the records. Approve only if it leaves the records as they are.',
142 }
143 })
144
145 // The same guard for the shell, as far as a command can be read.
146 on('tool.check', { tool: 'Bash' }, async (_$, e, next) => {
147 const decided = await next(e)
148 const command =
149 typeof e.input === 'object' && e.input !== null && 'command' in e.input
150 ? e.input.command
151 : null
152 const reason =
153 decided.decision === 'deny' || typeof command !== 'string' ? null : bashReason(command)
154 return reason === null ? decided : { decision: 'ask', reason: `Project Maintainer: ${reason}` }
155 }).catch(async () => ({
156 decision: 'ask',
157 reason: 'Project Maintainer could not check this command against the rules of the records.',
158 }))
159
160 // A compaction summarizes the conversation; this keeps what the records need out of the loss.
161 on('session.compact', async ($, e, next) => {
162 if (e.agentId !== undefined || (e.trigger !== 'auto' && e.trigger !== 'manual')) {
163 return next(e)
164 }
165 const root = await $.session.root()
166 const hasRecords =
167 (await $.fs.exists(`${root}/.claude/maintainer`)) ||
168 (await $.fs.exists(`${root}/.claude/session-handoff`))
169 return hasRecords ? next({ ...e, instructions: withKeepNote(e.instructions) }) : next(e)
170 })
171
172 // After a commit that changes dependencies, schema, CI or infrastructure, or whose message says
173 // workaround, temporary or revert, remind Claude that a decision or a debt may be worth recording.
174 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
175 if (!nudge || !/\bgit\s+commit\b/.test(e.command)) {
176 return next(e)
177 }
178 const cwd = await $.session.cwd()
179 const before = await headOf($, cwd)
180 const result = await next(e)
181 const after = await headOf($, cwd)
182 if (after === '' || after === before) {
183 return result
184 }
185 try {
186 const shown = await $.process.run(
187 ['git', 'show', '--name-only', '--format=%s', '-1', 'HEAD'],
188 { cwd, timeoutMs: 5000 },
189 )
190 const signals = shown.exitCode === 0 ? commitSignals(shown.stdout) : []
191 if (signals.length > 0) {
192 await $.session.append({
193 message: { type: 'user', content: [{ type: 'text', text: nudgeText(signals) }] },
194 })
195 }
196 } catch {
197 // The reminder is a courtesy: the commit result stands whatever happens here.
198 }
199 return result
200 })
201}
202hooks/guard.ts 242 lines1// What the hooks decide, as pure functions over plain data. The hooks in register.ts read the
2// files and call these; no call on `$` lives here.
3
4import { adrAuthority, validateAdr } from './adr.ts'
5import { closedAuthority, pendingAuthority, validateLedger } from './debt.ts'
6import { classify, type RecordRef, resolvePath } from './paths.ts'
7
8/** The arguments of a Write or Edit call, narrowed from the unknown input of `tool.check`. */
9export type FileInput = {
10 path: string
11 content?: string
12 oldString?: string
13 newString?: string
14 replaceAll?: boolean
15}
16
17export function fileInput(value: unknown): FileInput | null {
18 if (typeof value !== 'object' || value === null) {
19 return null
20 }
21 const text = (key: string): string | undefined => {
22 const found: unknown = Reflect.get(value, key)
23 return typeof found === 'string' ? found : undefined
24 }
25 const path = text('file_path')
26 if (path === undefined) {
27 return null
28 }
29 const input: FileInput = { path }
30 const content = text('content')
31 const oldString = text('old_string')
32 const newString = text('new_string')
33 if (content !== undefined) {
34 input.content = content
35 }
36 if (oldString !== undefined) {
37 input.oldString = oldString
38 }
39 if (newString !== undefined) {
40 input.newString = newString
41 }
42 if (Reflect.get(value, 'replace_all') === true) {
43 input.replaceAll = true
44 }
45 return input
46}
47
48/**
49 * Whether a Write or Edit call may save a record, judged with no call on `$` so it can run when
50 * the check itself broke. Without the session's directory a relative path could land anywhere,
51 * so it counts as a record, as does a call it cannot read: when in doubt, the guard holds.
52 */
53export function mayWriteRecord(value: unknown): boolean {
54 try {
55 const input = fileInput(value)
56 if (input === null) {
57 return false
58 }
59 const absolute = /^([A-Za-z]:)?[\\/]/.test(input.path)
60 return !absolute || classify(resolvePath(input.path, '/')) !== null
61 } catch {
62 return true
63 }
64}
65
66/**
67 * The file as the call would leave it: Write replaces the content, Edit swaps one piece of
68 * the current text. Null when the call cannot apply, which the tool reports by itself.
69 */
70export function resultingText(input: FileInput, current: string | null): string | null {
71 if (input.content !== undefined) {
72 return input.content
73 }
74 const { oldString, newString } = input
75 if (
76 current === null ||
77 oldString === undefined ||
78 newString === undefined ||
79 !current.includes(oldString)
80 ) {
81 return null
82 }
83 return input.replaceAll === true
84 ? current.replaceAll(oldString, () => newString)
85 : current.replace(oldString, () => newString)
86}
87
88/** What the hooks know about the files around a record. */
89export type Surroundings = {
90 /** The text of the other debt ledger, or null when it does not exist. */
91 otherLedger: string | null
92 /** The IDs of the ADRs in the decisions folder. */
93 adrIds: ReadonlySet<string>
94}
95
96/** Rules nobody can skip: a record that breaks them is malformed. */
97export function formatProblems(
98 ref: RecordRef,
99 current: string | null,
100 resulting: string,
101 around: Surroundings,
102): string[] {
103 if (ref.kind === 'adr') {
104 return validateAdr(resulting, { name: ref.name, exists: current !== null, ids: around.adrIds })
105 }
106 return validateLedger(ref.kind, current, resulting, around.otherLedger)
107}
108
109/** Rules the owner may waive: a settled record changes only through a new record. */
110export function authorityReasons(
111 ref: RecordRef,
112 current: string | null,
113 resulting: string,
114 around: Surroundings,
115): string[] {
116 if (current === null) {
117 return []
118 }
119 if (ref.kind === 'adr') {
120 return adrAuthority(current, resulting)
121 }
122 if (ref.kind === 'closed-debt') {
123 return closedAuthority(current, resulting)
124 }
125 return pendingAuthority(current, resulting, around.otherLedger, around.adrIds)
126}
127
128export function refusal(file: string, problems: readonly string[]): string {
129 return [
130 `Project Maintainer refused to save ${file}. Fix these and write it again:`,
131 ...problems.map((problem) => `- ${problem}`),
132 ].join('\n')
133}
134
135export function askReason(file: string, reasons: readonly string[]): string {
136 return [
137 `Project Maintainer: this edit to ${file} breaks a rule of the records.`,
138 ...reasons.map((reason) => `- ${reason}`),
139 'A settled record is not rewritten. Write a new ADR that supersedes it, or open a new debt item that links back. Approve only if you asked for this edit. Where nobody can approve, write the new record instead.',
140 ].join('\n')
141}
142
143/** Whether a piece of a shell command names the records folder, with or without a trailing slash. */
144const mentionsRecords = (piece: string): boolean => /\.claude\/maintainer(?![\w-])/i.test(piece)
145
146const unquote = (token: string): string => token.replace(/^['"]|['"]$/g, '')
147const SEGMENTS = /&&|\|\||[;|\n]/
148const REMOVES_OR_MOVES = new Set(['rm', 'mv', 'unlink', 'shred', 'truncate'])
149const WRITES_LAST_ARGUMENT = new Set(['cp', 'install', 'ln'])
150
151/**
152 * Whether a shell command rewrites, moves or deletes the records folders, or writes into them.
153 * Reading, and copying out of the folder, stay quiet. Best effort: a shell has too many spellings.
154 */
155export function bashReason(command: string): string | null {
156 let insideRecords = false
157 for (const segment of command.split(SEGMENTS)) {
158 const tokens = segment
159 .trim()
160 .split(/\s+/)
161 .map(unquote)
162 .filter((token) => token !== '')
163 const [first = '', ...rest] = tokens
164 const verb = first === 'git' ? (rest[0] ?? '') : first
165 const args = first === 'git' ? rest.slice(1) : rest
166 if (verb === 'cd') {
167 insideRecords = insideRecords || args.some(mentionsRecords)
168 continue
169 }
170 const names = args.some(mentionsRecords) || insideRecords
171 const redirect = tokens.some(
172 (token, index) =>
173 /^\d*>>?/.test(token) &&
174 (mentionsRecords(token) ||
175 (/^\d*>>?$/.test(token) && mentionsRecords(tokens[index + 1] ?? ''))),
176 )
177 const inPlace =
178 (verb === 'sed' || verb === 'perl') && args.some((arg) => /^-\w*i|^--in-place/.test(arg))
179 const flagged =
180 redirect ||
181 (insideRecords && tokens.some((token) => /^\d*>>?/.test(token))) ||
182 (REMOVES_OR_MOVES.has(verb) && names) ||
183 (WRITES_LAST_ARGUMENT.has(verb) && (mentionsRecords(args.at(-1) ?? '') || insideRecords)) ||
184 (verb === 'tee' && names) ||
185 (verb === 'dd' &&
186 args.some((arg) => arg.startsWith('of=') && (mentionsRecords(arg) || insideRecords))) ||
187 (inPlace && names)
188 if (flagged) {
189 return 'The command changes files in .claude/maintainer/. Records are written with the Write and Edit tools, which apply the rules of the records, and not through the shell.'
190 }
191 }
192 return null
193}
194
195/** Appended to the instructions of a compaction so the summary keeps what the records need. */
196export const KEEP_IN_SUMMARY =
197 'Keep verbatim: every decision made and the reason for it, every correction the user gave, every open item, and every ADR_ and DEBT- ID mentioned, so the project records can be reconciled after the compaction.'
198
199export function withKeepNote(instructions: string | undefined): string {
200 return instructions === undefined || instructions.trim() === ''
201 ? KEEP_IN_SUMMARY
202 : `${instructions}\n\n${KEEP_IN_SUMMARY}`
203}
204
205const SIGNAL_FILES: readonly (readonly [RegExp, string])[] = [
206 [
207 /(^|\/)(package\.json|pyproject\.toml|requirements[\w.-]*\.txt|Pipfile|go\.mod|Cargo\.toml|Gemfile|composer\.json|pom\.xml|build\.gradle(\.kts)?)$/,
208 'a dependency manifest',
209 ],
210 [/(^|\/)(Dockerfile[\w.-]*|docker-compose[\w.-]*\.ya?ml)$|\.tf$/, 'infrastructure files'],
211 [/(^|\/)migrations?\/|\.sql$|(^|\/)schema\.[\w]+$/, 'a database schema or migration'],
212 [/^\.github\/workflows\//, 'a CI workflow'],
213]
214const SIGNAL_WORDS =
215 /\b(workaround|work-around|temporary|temporarily|hack|hacky|revert(ed)?|todo|fixme|deprecat\w*|drop(ped)? support|migrat\w*)\b/i
216
217/** Why a commit may deserve a record. The first line is the subject; the rest are the files it touched. */
218export function commitSignals(shown: string): string[] {
219 const [subject = '', ...rest] = shown.split('\n')
220 const files = rest.map((line) => line.trim()).filter((line) => line !== '')
221 if (files.length === 0 || files.every((file) => file.startsWith('.claude/maintainer/'))) {
222 return []
223 }
224 const signals = new Set<string>()
225 for (const file of files) {
226 for (const [pattern, label] of SIGNAL_FILES) {
227 if (pattern.test(file)) {
228 signals.add(label)
229 }
230 }
231 }
232 const word = SIGNAL_WORDS.exec(subject)
233 if (word !== null) {
234 signals.add(`the word "${word[0]}" in its message`)
235 }
236 return [...signals]
237}
238
239export function nudgeText(signals: readonly string[]): string {
240 return `Project Maintainer: the commit just made shows ${signals.join(' and ')}. If it settles a decision or leaves a problem that cannot be fixed now, record it with the write-records skill. If not, ignore this note.`
241}
242hooks/paths.ts 85 lines1// The words every guard shares with the skills and their templates: folders, file names,
2// statuses and outcomes. Change one here only together with the skill text that names it.
3
4export const ADR_STATUSES = [
5 'proposed',
6 'accepted',
7 'rejected',
8 'deprecated',
9 'superseded',
10] as const
11export const DEBT_STATUSES = ['Pending', 'In progress', 'Blocked', 'Risk accepted'] as const
12export const OUTCOMES = ['resolved', 'obsolete', 'accepted'] as const
13
14export const ADR_NAME = /^ADR_(\d{4}-\d{2}-\d{2})_([a-z0-9]+(?:-[a-z0-9]+)*)\.md$/
15export const ADR_ID = /^ADR_\d{4}-\d{2}-\d{2}_[a-z0-9]+(?:-[a-z0-9]+)*$/
16export const DEBT_ID = /^DEBT-\d{3,}$/
17
18export type RecordKind = 'adr' | 'pending-debt' | 'closed-debt'
19
20export type RecordRef = {
21 kind: RecordKind
22 /** The folder that holds the file, with forward slashes. */
23 dir: string
24 name: string
25}
26
27const RECORD_PATH = /^(.*\/\.claude\/maintainer)\/(decisions|technical-debt)\/([^/]+)$/i
28
29/**
30 * The absolute path a tool call really writes: relative paths are taken from the session's
31 * directory, and `.`, `..`, repeated slashes and backslashes are folded away.
32 */
33export function resolvePath(path: string, cwd: string): string {
34 const slashed = path.replaceAll('\\', '/')
35 const absolute = /^([A-Za-z]:)?\//.test(slashed)
36 ? slashed
37 : `${cwd.replaceAll('\\', '/')}/${slashed}`
38 const drive = /^[A-Za-z]:/.exec(absolute)?.[0] ?? ''
39 const parts: string[] = []
40 for (const part of absolute.slice(drive.length).split('/')) {
41 if (part === '..') {
42 parts.pop()
43 } else if (part !== '' && part !== '.') {
44 parts.push(part)
45 }
46 }
47 return `${drive}/${parts.join('/')}`
48}
49
50/** Says which record a resolved path is, or null for any other file. A case-insensitive disk folds the case. */
51export function classify(path: string): RecordRef | null {
52 const match = RECORD_PATH.exec(path.replaceAll('\\', '/'))
53 if (match === null) {
54 return null
55 }
56 const [, base = '', folder = '', name = ''] = match
57 if (folder.toLowerCase() === 'decisions') {
58 return name.toLowerCase().endsWith('.md')
59 ? { kind: 'adr', dir: `${base}/${folder}`, name }
60 : null
61 }
62 if (name.toLowerCase() === 'pending-debt.md') {
63 return { kind: 'pending-debt', dir: `${base}/${folder}`, name }
64 }
65 if (name.toLowerCase() === 'closed-debt.md') {
66 return { kind: 'closed-debt', dir: `${base}/${folder}`, name }
67 }
68 return null
69}
70
71/** The ID of an ADR is its file name without the extension. */
72export function adrId(name: string): string {
73 return name.replace(/\.md$/, '')
74}
75
76/** Line endings and trailing spaces never count as a change. */
77export function normalize(text: string): string {
78 return text
79 .replaceAll('\r\n', '\n')
80 .split('\n')
81 .map((line) => line.trimEnd())
82 .join('\n')
83 .trimEnd()
84}
85hooks/adr.ts 196 lines1// ADR rules, as pure functions over plain text. No call on `$` lives here, so the same code
2// runs in the hooks and in the tests.
3
4import { ADR_ID, ADR_NAME, ADR_STATUSES, adrId, normalize } from './paths.ts'
5
6export type Frontmatter = {
7 scalars: Map<string, string>
8 lists: Map<string, string[]>
9}
10
11function unquote(value: string): string {
12 const trimmed = value.trim()
13 const quote = trimmed[0]
14 if ((quote === '"' || quote === "'") && trimmed.length >= 2 && trimmed.endsWith(quote)) {
15 return trimmed.slice(1, -1)
16 }
17 return trimmed
18}
19
20/** Reads the YAML frontmatter the ADR template uses: scalars and lists of scalars. */
21export function parseFrontmatter(text: string): Frontmatter | null {
22 const lines = normalize(text).split('\n')
23 if (lines[0] !== '---') {
24 return null
25 }
26 const end = lines.indexOf('---', 1)
27 if (end === -1) {
28 return null
29 }
30 const scalars = new Map<string, string>()
31 const lists = new Map<string, string[]>()
32 let listKey: string | null = null
33 for (const line of lines.slice(1, end)) {
34 const item = /^\s+-\s+(.*)$/.exec(line)
35 if (item !== null && listKey !== null) {
36 lists.get(listKey)?.push(unquote(item[1] ?? ''))
37 continue
38 }
39 const pair = /^([A-Za-z][\w-]*):\s*(.*)$/.exec(line)
40 if (pair === null) {
41 listKey = null
42 continue
43 }
44 const key = pair[1] ?? ''
45 const value = (pair[2] ?? '').trim()
46 if (value === '') {
47 listKey = key
48 lists.set(key, [])
49 } else {
50 listKey = null
51 scalars.set(key, unquote(value))
52 }
53 }
54 return { scalars, lists }
55}
56
57const isPlaceholder = (value: string): boolean => /^\{.*\}$/.test(value.trim())
58
59export type AdrContext = {
60 name: string
61 /** True when the file is already on disk. */
62 exists: boolean
63 /** The IDs of the ADRs in the folder. */
64 ids: ReadonlySet<string>
65}
66
67/** What is wrong with an ADR as it would be saved. An empty list means the record is well formed. */
68export function validateAdr(text: string, context: AdrContext): string[] {
69 const nameMatch = ADR_NAME.exec(context.name)
70 if (nameMatch === null) {
71 // A file that was already there and is not an ADR is not ours to judge.
72 return context.exists
73 ? []
74 : [
75 `Name the file ADR_<YYYY-MM-DD>_<slug>.md with a lowercase kebab-case slug. "${context.name}" does not match.`,
76 ]
77 }
78 const frontmatter = parseFrontmatter(text)
79 if (frontmatter === null) {
80 return [
81 'Start the file with the YAML frontmatter of the ADR template: status, date, decision-makers, supersedes and superseded-by.',
82 ]
83 }
84 const problems: string[] = []
85 const status = frontmatter.scalars.get('status') ?? ''
86 if (!(ADR_STATUSES as readonly string[]).includes(status)) {
87 problems.push(`status must be one of ${ADR_STATUSES.join(', ')}. Found "${status}".`)
88 }
89 const date = frontmatter.scalars.get('date') ?? ''
90 if (date !== nameMatch[1]) {
91 problems.push(
92 `date must equal the date in the file name (${nameMatch[1] ?? ''}). Found "${date}".`,
93 )
94 }
95 const makers = frontmatter.lists.get('decision-makers') ?? []
96 if (makers.length === 0 || makers.some((maker) => maker === '' || isPlaceholder(maker))) {
97 problems.push(
98 'decision-makers must list at least one real person or role, with no template placeholder.',
99 )
100 }
101 for (const key of ['consulted', 'informed'] as const) {
102 if (
103 (frontmatter.lists.get(key) ?? []).some((person) => person === '' || isPlaceholder(person))
104 ) {
105 problems.push(
106 `Remove the ${key} lines, or name a real person or role: ${key} still holds a template placeholder.`,
107 )
108 }
109 }
110 const selfId = adrId(context.name)
111 for (const key of ['supersedes', 'superseded-by'] as const) {
112 const value = frontmatter.scalars.get(key)
113 if (value === undefined) {
114 problems.push(`Add "${key}: none" to the frontmatter.`)
115 } else if (value !== 'none') {
116 if (!ADR_ID.test(value)) {
117 problems.push(`${key} holds an ID, the ADR file name without .md. Found "${value}".`)
118 } else if (value === selfId) {
119 problems.push(`${key} cannot point at the record itself.`)
120 } else if (!context.ids.has(value)) {
121 problems.push(`${key} points at ${value}, which has no file in the decisions folder.`)
122 }
123 }
124 }
125 if (status === 'superseded' && (frontmatter.scalars.get('superseded-by') ?? 'none') === 'none') {
126 problems.push(
127 'A superseded record needs superseded-by set to the ID of the record that replaces it. Change status and superseded-by in one edit.',
128 )
129 }
130 if (
131 /Template for \.claude\/maintainer\/decisions\//.test(text) ||
132 /<!--\s*Optional:/.test(text)
133 ) {
134 problems.push('Remove the template comments from the record.')
135 }
136 const title = /^# (.+)$/m.exec(normalize(text))
137 if (title === null || isPlaceholder(title[1] ?? '')) {
138 problems.push('Give the record a title: one "# " heading that names the decision.')
139 }
140 return problems
141}
142
143/** Drops the one section a settled ADR may still fill in: Confirmation, up to the next heading. */
144function withoutConfirmation(lines: string[]): string[] {
145 const start = lines.findIndex((line) => line.trim() === '### Confirmation')
146 if (start === -1) {
147 return lines
148 }
149 const next = lines.findIndex((line, index) => index > start && /^#{1,6} /.test(line))
150 return [...lines.slice(0, start), ...(next === -1 ? [] : lines.slice(next))]
151}
152
153/** The record with the parts a settled ADR may still change blanked out. */
154function settledShape(text: string): string {
155 const lines = normalize(text).split('\n')
156 const masked = lines.map((line) =>
157 /^(status|superseded-by):/.test(line) ? line.replace(/:.*$/, ': _') : line,
158 )
159 return withoutConfirmation(masked).join('\n')
160}
161
162/** The only status changes a settled record accepts. */
163const NEXT_STATUS: Readonly<Record<string, readonly string[]>> = {
164 accepted: ['superseded', 'deprecated'],
165 deprecated: ['superseded'],
166}
167
168/**
169 * Why an edit to an ADR needs the owner's approval. A `proposed` record is still open and
170 * changes freely. Any other record changes only its status, its superseded-by and its Confirmation.
171 */
172export function adrAuthority(current: string, resulting: string): string[] {
173 const before = parseFrontmatter(current)
174 const status = before?.scalars.get('status')
175 if (before === null || status === undefined || status === 'proposed') {
176 return []
177 }
178 const after = parseFrontmatter(resulting)
179 if (after === null) {
180 return ['The edit removes the frontmatter of a settled ADR.']
181 }
182 const reasons: string[] = []
183 const nextStatus = after.scalars.get('status') ?? ''
184 if (nextStatus !== status && !(NEXT_STATUS[status] ?? []).includes(nextStatus)) {
185 reasons.push(
186 `The edit changes the status of a settled ADR from ${status} to ${nextStatus || 'nothing'}.`,
187 )
188 }
189 if (settledShape(current) !== settledShape(resulting)) {
190 reasons.push(
191 'The edit changes the text of a settled ADR beyond its status, superseded-by and Confirmation section.',
192 )
193 }
194 return reasons
195}
196hooks/debt.ts 235 lines1// Debt ledger rules, as pure functions over plain text. No call on `$` lives here.
2
3import { ADR_ID, DEBT_ID, DEBT_STATUSES, normalize, OUTCOMES } from './paths.ts'
4
5export type Entry = {
6 id: string
7 text: string
8}
9
10export type Ledger = {
11 entries: Entry[]
12 /** The cells of each Index row. */
13 index: string[][]
14}
15
16/** The "Entry format" block of a ledger is an HTML comment; it holds no real entry. */
17const stripComments = (text: string): string => text.replace(/<!--[\s\S]*?-->/g, '')
18
19function cells(line: string): string[] {
20 return line
21 .split('|')
22 .slice(1, -1)
23 .map((cell) => cell.trim())
24}
25
26/** Splits a ledger into its entries (the "### DEBT-NNN" blocks) and the rows of its Index. */
27export function parseLedger(text: string): Ledger {
28 const lines = normalize(stripComments(text)).split('\n')
29 const entries: Entry[] = []
30 const index: string[][] = []
31 let open: { id: string; lines: string[] } | null = null
32 let inIndex = false
33 const close = (): void => {
34 if (open !== null) {
35 entries.push({ id: open.id, text: open.lines.join('\n').trimEnd() })
36 open = null
37 }
38 }
39 for (const line of lines) {
40 const heading = /^### (DEBT-\w+)/.exec(line)
41 if (heading !== null) {
42 close()
43 open = { id: heading[1] ?? '', lines: [line] }
44 inIndex = false
45 continue
46 }
47 if (/^#{1,3} /.test(line)) {
48 close()
49 inIndex = /^## .*Index/.test(line)
50 continue
51 }
52 if (open !== null) {
53 open.lines.push(line)
54 } else if (inIndex && line.startsWith('|') && !/^\|\s*-/.test(line)) {
55 index.push(cells(line))
56 }
57 }
58 close()
59 return { entries, index: index.slice(1) }
60}
61
62const field = (entry: string, name: string): string | null => {
63 const match = new RegExp(`^\\|\\s*\\*\\*${name}\\*\\*\\s*\\|\\s*(.*?)\\s*\\|\\s*$`, 'm').exec(
64 entry,
65 )
66 return match === null ? null : (match[1] ?? '')
67}
68
69const isDate = (value: string | null): boolean =>
70 value !== null && /^\d{4}-\d{2}-\d{2}$/.test(value)
71
72export type LedgerKind = 'pending-debt' | 'closed-debt'
73
74/**
75 * What is wrong with a ledger as it would be saved. An entry is judged only when it is new or
76 * changed, so a record already on disk never blocks an unrelated edit.
77 * `other` is the other ledger, or null when it does not exist.
78 */
79export function validateLedger(
80 kind: LedgerKind,
81 current: string | null,
82 resulting: string,
83 other: string | null,
84): string[] {
85 const problems: string[] = []
86 const after = parseLedger(resulting)
87 const before = current === null ? null : parseLedger(current)
88 const known = new Map((before?.entries ?? []).map((entry) => [entry.id, entry.text]))
89 const seen = new Set<string>()
90 const otherIds = new Set(
91 other === null ? [] : parseLedger(other).entries.map((entry) => entry.id),
92 )
93 for (const entry of after.entries) {
94 if (!DEBT_ID.test(entry.id)) {
95 problems.push(
96 `${entry.id} is not a valid ID. Use DEBT- and at least three digits, such as DEBT-007.`,
97 )
98 continue
99 }
100 if (seen.has(entry.id)) {
101 problems.push(`${entry.id} appears twice in this ledger. Never reuse an ID.`)
102 }
103 seen.add(entry.id)
104 const unchanged = known.get(entry.id) === entry.text
105 if (unchanged) {
106 continue
107 }
108 // A reopened problem gets a new ID. Only moving a pending item to closed puts one ID in both.
109 if (kind === 'pending-debt' && !known.has(entry.id) && otherIds.has(entry.id)) {
110 problems.push(
111 `${entry.id} is already in the closed ledger. A problem that comes back gets a new ID and links back to the closed one.`,
112 )
113 }
114 problems.push(...entryProblems(kind, entry))
115 }
116 if (kind === 'closed-debt') {
117 for (const row of after.index) {
118 const outcome = row[2] ?? ''
119 if (row.length > 0 && !(OUTCOMES as readonly string[]).includes(outcome)) {
120 problems.push(
121 `The Index row of ${row[1] ?? 'an item'} has outcome "${outcome}". Use ${OUTCOMES.join(', ')}.`,
122 )
123 }
124 }
125 }
126 return problems
127}
128
129/** Text with its code removed, so a literal `{{ x }}` in a code span is not taken for a placeholder. */
130const withoutCode = (text: string): string =>
131 text.replace(/```[\s\S]*?```/g, '').replace(/`[^`\n]*`/g, '')
132
133function entryProblems(kind: LedgerKind, entry: Entry): string[] {
134 const problems: string[] = []
135 if (withoutCode(entry.text).includes('{{')) {
136 problems.push(`${entry.id}: replace every {{placeholder}} of the template.`)
137 }
138 if (kind === 'pending-debt') {
139 const status = field(entry.text, 'Status') ?? ''
140 if (!DEBT_STATUSES.some((name) => status.includes(name))) {
141 problems.push(`${entry.id}: Status must be one of ${DEBT_STATUSES.join(', ')}.`)
142 }
143 if (!isDate(field(entry.text, 'Logged'))) {
144 problems.push(`${entry.id}: Logged must be a date, YYYY-MM-DD.`)
145 }
146 return problems
147 }
148 const outcome = field(entry.text, 'Outcome') ?? ''
149 if (!(OUTCOMES as readonly string[]).includes(outcome)) {
150 problems.push(`${entry.id}: Outcome must be one of ${OUTCOMES.join(', ')}.`)
151 }
152 for (const name of ['Closed on', 'Logged on']) {
153 if (!isDate(field(entry.text, name))) {
154 problems.push(`${entry.id}: ${name} must be a date, YYYY-MM-DD.`)
155 }
156 }
157 const owner = field(entry.text, 'Owner') ?? ''
158 if (owner === '' || owner.startsWith('{{')) {
159 problems.push(`${entry.id}: copy Owner from the pending entry.`)
160 }
161 return problems
162}
163
164/** The closed entry with its one editable field blanked out. */
165const settledEntry = (text: string): string =>
166 text.replace(/^(- \*\*Superseded by:\*\*).*$/m, '$1 _')
167
168/** Why an edit to a closed ledger needs the owner's approval: it rewrites a closed record. */
169export function closedAuthority(current: string, resulting: string): string[] {
170 const before = parseLedger(current)
171 const after = new Map(parseLedger(resulting).entries.map((entry) => [entry.id, entry.text]))
172 const reasons: string[] = []
173 for (const entry of before.entries) {
174 const next = after.get(entry.id)
175 if (next === undefined) {
176 reasons.push(`The edit removes the closed entry ${entry.id}.`)
177 } else if (settledEntry(next) !== settledEntry(entry.text)) {
178 reasons.push(
179 `The edit changes the closed entry ${entry.id} beyond its "Superseded by" field.`,
180 )
181 }
182 }
183 const rows = new Set(parseLedger(resulting).index.map((row) => row.slice(0, -1).join('|')))
184 for (const row of before.index) {
185 if (!rows.has(row.slice(0, -1).join('|'))) {
186 reasons.push(
187 `The edit changes the Index row of ${row[1] ?? 'a closed item'} beyond its "Superseded by" cell.`,
188 )
189 }
190 }
191 return reasons
192}
193
194/**
195 * Why an edit to the pending ledger needs the owner's approval: it removes an item that has
196 * no closed entry yet. `closed` is the closed ledger, or null when it does not exist, and
197 * `adrIds` are the IDs of the ADRs in the decisions folder.
198 */
199export function pendingAuthority(
200 current: string,
201 resulting: string,
202 closed: string | null,
203 adrIds: ReadonlySet<string>,
204): string[] {
205 const kept = new Set(parseLedger(resulting).entries.map((entry) => entry.id))
206 const closedEntries = new Map(
207 (closed === null ? [] : parseLedger(closed).entries).map((entry) => [entry.id, entry.text]),
208 )
209 const reasons: string[] = []
210 for (const { id } of parseLedger(current).entries) {
211 if (kept.has(id)) {
212 continue
213 }
214 const entry = closedEntries.get(id)
215 const outcome = entry === undefined ? null : field(entry, 'Outcome')
216 if (
217 entry === undefined ||
218 outcome === null ||
219 !(OUTCOMES as readonly string[]).includes(outcome)
220 ) {
221 reasons.push(
222 `The edit removes ${id} from the pending ledger, and the closed ledger has no entry with an outcome for it.`,
223 )
224 } else if (
225 outcome === 'accepted' &&
226 !(entry.match(/ADR_[\w-]+/g) ?? []).some((cited) => ADR_ID.test(cited) && adrIds.has(cited))
227 ) {
228 reasons.push(
229 `${id} is closed as accepted, and its closed entry cites no ADR that exists in the decisions folder.`,
230 )
231 }
232 }
233 return reasons
234}
235