Architectural decision log
decisions_logRecord, list, and search architectural decisions. Store decisions with title, body, category, importance; supersede old ones. List recent decisions or search via full-text query. Helps AI agents retain project context across sessions.
Instructions
Multi-action ADR log. Pick one via action: • store — record a new decision (title + body required; supersedes_id optional). Writes a row. • list — list recent decisions (read-only). • search — FTS over decisions (query required, read-only). Decisions outrank free-form memories by default and survive pruning longer. Use pitfalls_log for recurring problems.
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| action | Yes | Which sub-operation to perform: `store`, `list`, or `search`. | |
| project_path | Yes | Absolute project path the decision belongs to (or to scope `list`/`search`). Required. | |
| title | No | Decision title (required for `store`, ignored otherwise). One short line — e.g. `"Use Postgres over MySQL"`. | |
| body | No | Decision body in markdown (required for `store`). Should explain context, options considered, and rationale. | |
| category | No | Free-form category tag for filtering, e.g. `architecture`, `infra`, `process`. Default `general`. | general |
| importance | No | Importance score in [0, 1] for `store`. Default 0.7 — decisions outrank typical facts (0.5). | |
| supersedes_id | No | For `store`: optional id of an earlier decision this one replaces. The older decision is marked superseded. | |
| query | No | Search text (required for `action="search"`). Tokenised by FTS5. | |
| limit | No | Maximum rows to return for `list`/`search` (1-50). Default 10. |
Output Schema
| Name | Required | Description | Default |
|---|---|---|---|
| message | Yes | For `store`: `Decision stored with ID: <id>`. For `list`/`search`: markdown bullet list of decisions or `No decisions found.` Returns `Invalid action: ...` when `action` is unsupported. |
Implementation Reference
- src/tools/decisions-log.ts:4-30 (handler)Main handler function for the 'decisions_log' tool. Dispatches to 'store', 'list', or 'search' actions on the DecisionsRepo.
export async function handleDecisionsLog(repo: DecisionsRepo, params: { action: string; project_path: string; title?: string; body?: string; category?: string; importance?: number; supersedes_id?: string; query?: string; limit?: number; }): Promise<string> { if (params.action === "store") { if (!params.title || !params.body) return "title and body are required for action='store'."; const id = repo.store(params.project_path, params.title, params.body, params.category, params.importance, params.supersedes_id); return `Decision stored with ID: ${id}`; } if (params.action === "list") { const decisions = repo.list(params.project_path, params.limit); if (!decisions.length) return "No decisions found."; return decisions.map(d => `[${d.category}] ${d.title}\n ID: ${d.id}\n ${(d.body as string).slice(0, 300)}\n Importance: ${d.importance_score} | Created: ${d.created_at}` ).join("\n\n"); } if (params.action === "search") { if (!params.query) return "query is required for action='search'."; const results = repo.search(params.query, params.project_path, params.limit); if (!results.length) return "No decisions found."; return results.map(d => `[${d.category}] ${d.title}\n ID: ${d.id}\n ${(d.body as string).slice(0, 300)}` ).join("\n\n"); } return `Invalid action: ${params.action}. Use 'store', 'list', or 'search'.`; } - src/index.ts:450-460 (schema)Input schema for decisions_log: defines action, project_path, title, body, category, importance, supersedes_id, query, and limit parameters with Zod validation.
inputSchema: { action: z.enum(["store", "list", "search"]).describe("Which sub-operation to perform: `store`, `list`, or `search`."), project_path: z.string().describe("Absolute project path the decision belongs to (or to scope `list`/`search`). Required."), title: z.string().default("").describe("Decision title (required for `store`, ignored otherwise). One short line — e.g. `\"Use Postgres over MySQL\"`."), body: z.string().default("").describe("Decision body in markdown (required for `store`). Should explain context, options considered, and rationale."), category: z.string().default("general").describe("Free-form category tag for filtering, e.g. `architecture`, `infra`, `process`. Default `general`."), importance: z.number().min(0).max(1).default(0.7).describe("Importance score in [0, 1] for `store`. Default 0.7 — decisions outrank typical facts (0.5)."), supersedes_id: z.string().default("").describe("For `store`: optional id of an earlier decision this one replaces. The older decision is marked superseded."), query: z.string().default("").describe("Search text (required for `action=\"search\"`). Tokenised by FTS5."), limit: z.number().int().min(1).max(50).default(10).describe("Maximum rows to return for `list`/`search` (1-50). Default 10."), }, - src/index.ts:468-470 (schema)Output schema for decisions_log: returns a message string describing the result.
outputSchema: { message: z.string().describe("For `store`: `Decision stored with ID: <id>`. For `list`/`search`: markdown bullet list of decisions or `No decisions found.` Returns `Invalid action: ...` when `action` is unsupported."), }, - src/index.ts:439-473 (registration)Registration of the 'decisions_log' tool on the server with title, description, input/output schemas, annotations, and handler binding.
server.registerTool( "decisions_log", { title: "Architectural decision log", description: [ "Multi-action ADR log. Pick one via `action`:", " • `store` — record a new decision (`title` + `body` required; `supersedes_id` optional). Writes a row.", " • `list` — list recent decisions (read-only).", " • `search` — FTS over decisions (`query` required, read-only).", "Decisions outrank free-form memories by default and survive pruning longer. Use `pitfalls_log` for recurring problems.", ].join(" "), inputSchema: { action: z.enum(["store", "list", "search"]).describe("Which sub-operation to perform: `store`, `list`, or `search`."), project_path: z.string().describe("Absolute project path the decision belongs to (or to scope `list`/`search`). Required."), title: z.string().default("").describe("Decision title (required for `store`, ignored otherwise). One short line — e.g. `\"Use Postgres over MySQL\"`."), body: z.string().default("").describe("Decision body in markdown (required for `store`). Should explain context, options considered, and rationale."), category: z.string().default("general").describe("Free-form category tag for filtering, e.g. `architecture`, `infra`, `process`. Default `general`."), importance: z.number().min(0).max(1).default(0.7).describe("Importance score in [0, 1] for `store`. Default 0.7 — decisions outrank typical facts (0.5)."), supersedes_id: z.string().default("").describe("For `store`: optional id of an earlier decision this one replaces. The older decision is marked superseded."), query: z.string().default("").describe("Search text (required for `action=\"search\"`). Tokenised by FTS5."), limit: z.number().int().min(1).max(50).default(10).describe("Maximum rows to return for `list`/`search` (1-50). Default 10."), }, annotations: { title: "Architectural decision log", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false, }, outputSchema: { message: z.string().describe("For `store`: `Decision stored with ID: <id>`. For `list`/`search`: markdown bullet list of decisions or `No decisions found.` Returns `Invalid action: ...` when `action` is unsupported."), }, }, async (params) => textResult(await handleDecisionsLog(decRepo, params)) ); - src/db/decisions.ts:16-71 (helper)DecisionsRepo class providing store(), list(), and search() methods that the handler delegates to for database operations.
export class DecisionsRepo { constructor(private db: Database.Database) {} private getOrCreateProject(rootPath: string): string { const row = this.db.prepare("SELECT id FROM projects WHERE root_path = ?").get(rootPath) as any; if (row) return row.id; const id = randomUUID(); this.db.prepare("INSERT INTO projects (id, name, root_path) VALUES (?, ?, ?)").run(id, rootPath.split("/").pop() ?? rootPath, rootPath); return id; } store(projectPath: string, title: string, body: string, category = "general", importance = 0.7, supersedesId?: string): string { const projectId = this.getOrCreateProject(projectPath); const id = randomUUID(); const now = nowIso(); if (supersedesId) { this.db.prepare("UPDATE decisions SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL").run(now, supersedesId); } // Issue #12: scrub secrets from title and body at write time. const cleanTitle = scrubSecrets(title); const cleanBody = scrubSecrets(body); if (cleanTitle !== title) { logger.warn(`Secret pattern detected and scrubbed in decision title (id=${id})`); } if (hasPrivate(title)) { logger.warn(`Warning: <private> tags detected in decision title — tags do not redact in titles. Move sensitive content to body. (id=${id})`); } // Issue #4: set has_private flag on store. const hasPrivateFlag = hasPrivate(cleanBody) ? 1 : 0; this.db.prepare(` INSERT INTO decisions (id, project_id, title, body, category, importance_score, supersedes_id, has_private, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `).run(id, projectId, cleanTitle, cleanBody, category, importance, supersedesId ?? null, hasPrivateFlag, now); return id; } list(projectPath: string, limit = 10): any[] { const projectId = this.getOrCreateProject(projectPath); return this.db.prepare(` SELECT * FROM decisions WHERE project_id = ? AND deleted_at IS NULL ORDER BY importance_score DESC, created_at DESC LIMIT ? `).all(projectId, limit) as any[]; } search(query: string, projectPath: string, limit = 10): any[] { const projectId = this.getOrCreateProject(projectPath); const ftsQuery = buildFtsQuery(query); if (!ftsQuery) return []; return this.db.prepare(` SELECT d.*, rank FROM decisions_fts fts JOIN decisions d ON d.rowid = fts.rowid WHERE decisions_fts MATCH ? AND d.project_id = ? AND d.deleted_at IS NULL ORDER BY rank LIMIT ? `).all(ftsQuery, projectId, limit) as any[]; } }