SLOPSHOPPER

Project Maintainer

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…

newguardprocess
A shopper browsing a rack in a slop shop
README

🧾 Project Maintainer

Version Works in Category Claude Code License: MIT

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 .claude folder: an ADR for the decision, a debt entry for the problem. Type /project-maintainer:write-handoff before 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.

✨ Why use it

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.

  • Remember why, not only what — each decision keeps its context, the options you rejected and the consequences you accepted, in one file per decision.
  • Debt that waits does not disappear — each entry holds its evidence, owner, next action and the condition that lets you close it, so a problem you chose to postpone stays visible.
  • Closed means proven — a debt item closes in one of three ways, each with its evidence: resolved (a check shows the fix works and a second check shows the old failure is gone), obsolete (the code or the architecture changed and the problem no longer exists) or accepted (you decided to live with it, and an ADR says so).
  • History stays true — a settled record is never rewritten. A new decision supersedes the old one and both point at each other.
  • Rules that hold — Claude cannot save a malformed record, and it needs your approval to change a decision you already settled or to drop a debt item without a closed entry. Guardrails enforce this in code, not only in instructions.
  • Start where you stopped — the handoff carries what was done, decided, corrected, learned and left open, and the next session reads it before it does what you ask.
  • One story, not two — each handoff is checked against the records. Every pending debt item is in it, closed items are closed with their evidence, and decisions you recorded since the last handoff are added. The next session reads the handoff, already knows what is open, and is told when the records changed after it was written.
  • Facts, not memory — records and handoffs mark what could not be verified, and the handoff is built from the repository itself: branch, uncommitted work, stashes, unpushed commits and open pull requests.
  • Only what matters — renames, typos and small refactors are not recorded, and a problem that can be fixed now is fixed instead of logged.
  • Plain Markdown in your project — no service and no account. Everything lives in your project's .claude folder.

🧩 What's inside

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.

ComponentNameWhat it does for youWhat it preventsHow it runs
🎯 Skillproject-maintainer:write-recordsWrites 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 whereDecisions and postponed problems that live only in a chat that is goneAutomatically when a decision is made or a problem is postponed, or with /project-maintainer:write-records
🎯 Skillproject-maintainer:write-handoffReads 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 itEnding 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
🪝 HookSessionStartShows 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 writtenA new session that starts from zero, or works from a handoff the records have since outdatedAutomatically, at the start of a session and after /clear
🪝 ModFormat guardChecks every save of an ADR or a debt ledger against the format and refuses a broken one, telling Claude what to fixA malformed record: a missing field, a repeated debt ID, a closed entry without an outcome, a link to a record that does not existAutomatically, on every save. Nobody can waive it. See Guardrails
🪝 ModAuthority guardAsks you before a settled record changes, and before a shell command rewrites, moves or deletes the records foldersClaude quietly rewriting history: editing a decision you accepted, changing a closed debt entry, or dropping a pending item without its closed entryAutomatically, on every save and shell command that touches the records. Only you can approve
🪝 ModCompaction noteTells Claude Code's summary to keep your decisions, the open items and every record IDLosing the thread of the records when a long conversation is compactedAutomatically, before a compaction in a project with records or handoffs
🪝 ModCommit reminderAfter 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 itA change that settles a decision going in with no record of why. It uses no model callAutomatically, 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.

✅ Requirements

RequirementDetails
Claude Codev2.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
SurfacesSee Compatibility
External toolsbash 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
NetworkRecords: 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 keysNone. git and gh use the credentials you already have

📥 Installation

# 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.

  1. Open Customize in the sidebar, then Plugins.
  2. Select Add → Add marketplace and enter nerymurillohnd/the-claude-factory.
  3. Find Project Maintainer in Discover and select Add.

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.

🚀 Usage

Record a decision

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.

Record several decisions at once

Result: Claude writes one ADR for each decision, so each one can be replaced on its own later.

Log a problem you cannot fix yet

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.

Close a debt item

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.

Change a decision

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.

Write a handoff before you stop

/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.

Pick up in the next 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.

Start fresh in the same session

/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.

Carry open items forward

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.

Bring in records you already keep

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.

Where everything is saved

WhatWhere
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.

Permissions

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 modeWhat happens when Claude saves a record or a handoff
Default or accept editsClaude 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.
AutoA classifier decides instead of you.
Bypass permissionsNo 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 as Edit(.claude/**), does not remove this prompt: Claude Code checks protected folders before it reads allow rules. See Protected paths in the Claude Code docs.

🚧 Guardrails

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 doWhat 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 outcomeRefused, 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 sectionClaude Code asks you
Change a closed debt entry beyond its "Superseded by" fieldClaude 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 commandClaude 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:

  • Compaction. Before a compaction, Claude Code's summary is told to keep your decisions, the open items and every ADR_… and DEBT-… ID, so the records can be reconciled afterwards. It happens only in a project that has records or handoffs.
  • Commit reminder. After a commit that changes a dependency manifest, a database schema or migration, a CI workflow or infrastructure files, or whose message says workaround, temporary, revert, TODO or similar, Claude gets one note that reminds it to record a decision or a debt if the commit settles one. It uses no model call. Turn it off with the Remind Claude after a commit that looks like a decision option. Claude Code asks about it when you install the plugin from a session with /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:

ItWhen
Handles tool.call and tool.check on Write, Edit and Bash, and session.compactOn 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": true in 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.

🔄 Updating

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

Source 5 files
hooks/register.ts 202 lines
1// 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}
202
hooks/guard.ts 242 lines
1// 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}
242
hooks/paths.ts 85 lines
1// 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}
85
hooks/adr.ts 196 lines
1// 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}
196
hooks/debt.ts 235 lines
1// 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