From c1a35dd1b3ae2af8d260cffdd80cf899ba622d2a Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Sat, 21 Mar 2026 16:26:28 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20ADR=20attribution=20=E2=80=94=20disting?= =?UTF-8?q?uish=20human=20vs=20agent=20vs=20collaborative=20decisions=20(#?= =?UTF-8?q?1830)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add made_by attribution field to decisions (human/agent/collaborative) Add a 'made_by' field to the Decision type that tracks whether a decision was made by the human, the agent, or collaboratively. This enables ADR-style accountability — you can always tell who actually made each call. Schema: - New DecisionMadeBy type: 'human' | 'agent' | 'collaborative' - DB schema v3 → v4: ALTER TABLE decisions ADD COLUMN made_by - Existing decisions default to 'agent' (backward compatible) - DECISIONS.md gains a 'Made By' column - Parser handles old 7-column format gracefully (defaults to 'agent') Surfaces updated: - gsd_save_decision tool accepts optional made_by parameter - Markdown generator/parser round-trips the new column - Prompt formatter shows attribution in LLM context - Compact formatter includes made_by in pipe-separated output - Worktree reconciliation includes made_by in conflict detection + merge Tests: 476 assertions across 9 test suites, all passing. * fix(gsd-db): resolve CI failures and address review findings - Update memory-store.test.ts to expect schema version 4 - Recreate active_decisions view in v4 migration to pick up new made_by column - Handle missing made_by column in older worktrees during reconciliation - Optimize VALID_MADE_BY Set by moving it outside the parser loop * fix(types): resolve missing made_by property errors in context-store and tests --- .../extensions/gsd/bootstrap/db-tools.ts | 9 +++- src/resources/extensions/gsd/context-store.ts | 7 +-- src/resources/extensions/gsd/db-writer.ts | 8 +++- src/resources/extensions/gsd/gsd-db.ts | 45 +++++++++++++++---- src/resources/extensions/gsd/md-importer.ts | 6 +++ .../gsd/structured-data-formatter.ts | 4 +- .../extensions/gsd/templates/decisions.md | 4 +- .../gsd/tests/context-store.test.ts | 15 ++++--- .../extensions/gsd/tests/db-writer.test.ts | 10 +++++ .../extensions/gsd/tests/gsd-db.test.ts | 9 +++- .../extensions/gsd/tests/md-importer.test.ts | 32 ++++++++++++- .../extensions/gsd/tests/memory-store.test.ts | 4 +- .../extensions/gsd/tests/prompt-db.test.ts | 4 +- .../tests/structured-data-formatter.test.ts | 7 +-- .../gsd/tests/worktree-db-integration.test.ts | 1 + .../extensions/gsd/tests/worktree-db.test.ts | 4 ++ src/resources/extensions/gsd/types.ts | 3 ++ 17 files changed, 142 insertions(+), 30 deletions(-) diff --git a/src/resources/extensions/gsd/bootstrap/db-tools.ts b/src/resources/extensions/gsd/bootstrap/db-tools.ts index 4b751abce..ade6cc996 100644 --- a/src/resources/extensions/gsd/bootstrap/db-tools.ts +++ b/src/resources/extensions/gsd/bootstrap/db-tools.ts @@ -16,8 +16,9 @@ export function registerDbTools(pi: ExtensionAPI): void { promptGuidelines: [ "Use gsd_save_decision when recording an architectural, pattern, library, or observability decision.", "Decision IDs are auto-assigned (D001, D002, ...) — never guess or provide an ID.", - "All fields except revisable and when_context are required.", + "All fields except revisable, when_context, and made_by are required.", "The tool writes to the DB and regenerates .gsd/DECISIONS.md automatically.", + "Set made_by to 'human' when the user explicitly directed the decision, 'agent' when the LLM chose autonomously (default), or 'collaborative' when it was discussed and agreed together.", ], parameters: Type.Object({ scope: Type.String({ description: "Scope of the decision (e.g. 'architecture', 'library', 'observability')" }), @@ -26,6 +27,11 @@ export function registerDbTools(pi: ExtensionAPI): void { rationale: Type.String({ description: "Why this choice was made" }), revisable: Type.Optional(Type.String({ description: "Whether this can be revisited (default: 'Yes')" })), when_context: Type.Optional(Type.String({ description: "When/context for the decision (e.g. milestone ID)" })), + made_by: Type.Optional(Type.Union([ + Type.Literal("human"), + Type.Literal("agent"), + Type.Literal("collaborative"), + ], { description: "Who made this decision: 'human' (user directed), 'agent' (LLM decided autonomously), or 'collaborative' (discussed and agreed). Default: 'agent'" })), }), async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { const dbAvailable = await ensureDbOpen(); @@ -45,6 +51,7 @@ export function registerDbTools(pi: ExtensionAPI): void { rationale: params.rationale, revisable: params.revisable, when_context: params.when_context, + made_by: params.made_by, }, process.cwd(), ); diff --git a/src/resources/extensions/gsd/context-store.ts b/src/resources/extensions/gsd/context-store.ts index 2ea66256a..b23f1e855 100644 --- a/src/resources/extensions/gsd/context-store.ts +++ b/src/resources/extensions/gsd/context-store.ts @@ -57,6 +57,7 @@ export function queryDecisions(opts?: DecisionQueryOpts): Decision[] { choice: row['choice'] as string, rationale: row['rationale'] as string, revisable: row['revisable'] as string, + made_by: (row['made_by'] as string as import('./types.js').DecisionMadeBy) ?? 'agent', superseded_by: null, })); } catch { @@ -121,10 +122,10 @@ export function queryRequirements(opts?: RequirementQueryOpts): Requirement[] { export function formatDecisionsForPrompt(decisions: Decision[]): string { if (decisions.length === 0) return ''; - const header = '| # | When | Scope | Decision | Choice | Rationale | Revisable? |'; - const separator = '|---|------|-------|----------|--------|-----------|------------|'; + const header = '| # | When | Scope | Decision | Choice | Rationale | Revisable? | Made By |'; + const separator = '|---|------|-------|----------|--------|-----------|------------|---------|'; const rows = decisions.map(d => - `| ${d.id} | ${d.when_context} | ${d.scope} | ${d.decision} | ${d.choice} | ${d.rationale} | ${d.revisable} |`, + `| ${d.id} | ${d.when_context} | ${d.scope} | ${d.decision} | ${d.choice} | ${d.rationale} | ${d.revisable} | ${d.made_by ?? 'agent'} |`, ); return [header, separator, ...rows].join('\n'); diff --git a/src/resources/extensions/gsd/db-writer.ts b/src/resources/extensions/gsd/db-writer.ts index 8d49761d6..2559d5e04 100644 --- a/src/resources/extensions/gsd/db-writer.ts +++ b/src/resources/extensions/gsd/db-writer.ts @@ -35,8 +35,8 @@ export function generateDecisionsMd(decisions: Decision[]): string { lines.push(' To reverse a decision, add a new row that supersedes it.'); lines.push(' Read this file at the start of any planning or research phase. -->'); lines.push(''); - lines.push('| # | When | Scope | Decision | Choice | Rationale | Revisable? |'); - lines.push('|---|------|-------|----------|--------|-----------|------------|'); + lines.push('| # | When | Scope | Decision | Choice | Rationale | Revisable? | Made By |'); + lines.push('|---|------|-------|----------|--------|-----------|------------|---------|'); for (const d of decisions) { // Escape pipe characters within cell values to preserve table structure @@ -48,6 +48,7 @@ export function generateDecisionsMd(decisions: Decision[]): string { d.choice, d.rationale, d.revisable, + d.made_by ?? 'agent', ].map(cell => (cell ?? '').replace(/\|/g, '\\|')); lines.push(`| ${cells.join(' | ')} |`); @@ -181,6 +182,7 @@ export interface SaveDecisionFields { rationale: string; revisable?: string; when_context?: string; + made_by?: import('./types.js').DecisionMadeBy; } /** @@ -205,6 +207,7 @@ export async function saveDecisionToDb( choice: fields.choice, rationale: fields.rationale, revisable: fields.revisable ?? 'Yes', + made_by: fields.made_by ?? 'agent', superseded_by: null, }); @@ -222,6 +225,7 @@ export async function saveDecisionToDb( choice: row['choice'] as string, rationale: row['rationale'] as string, revisable: row['revisable'] as string, + made_by: (row['made_by'] as string as import('./types.js').DecisionMadeBy) ?? 'agent', superseded_by: (row['superseded_by'] as string) ?? null, })); } diff --git a/src/resources/extensions/gsd/gsd-db.ts b/src/resources/extensions/gsd/gsd-db.ts index a31a2329e..bcd8c52b3 100644 --- a/src/resources/extensions/gsd/gsd-db.ts +++ b/src/resources/extensions/gsd/gsd-db.ts @@ -168,7 +168,7 @@ function openRawDb(path: string): unknown { // ─── Schema ──────────────────────────────────────────────────────────────── -const SCHEMA_VERSION = 3; +const SCHEMA_VERSION = 4; function initSchema(db: DbAdapter, fileBacked: boolean): void { // WAL mode for file-backed databases (must be outside transaction) @@ -195,6 +195,7 @@ function initSchema(db: DbAdapter, fileBacked: boolean): void { choice TEXT NOT NULL DEFAULT '', rationale TEXT NOT NULL DEFAULT '', revisable TEXT NOT NULL DEFAULT '', + made_by TEXT NOT NULL DEFAULT 'agent', superseded_by TEXT DEFAULT NULL ) `); @@ -360,6 +361,22 @@ function migrateSchema(db: DbAdapter): void { ).run({ ":version": 3, ":applied_at": new Date().toISOString() }); } + // v3 → v4: add made_by column to decisions table + if (currentVersion < 4) { + // Add made_by column — default 'agent' for existing rows (pre-attribution decisions) + db.exec(`ALTER TABLE decisions ADD COLUMN made_by TEXT NOT NULL DEFAULT 'agent'`); + + // Recreate views to pick up new columns (SQLite expands SELECT * at view creation time) + db.exec("DROP VIEW IF EXISTS active_decisions"); + db.exec( + "CREATE VIEW active_decisions AS SELECT * FROM decisions WHERE superseded_by IS NULL", + ); + + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ ":version": 4, ":applied_at": new Date().toISOString() }); + } + db.exec("COMMIT"); } catch (err) { db.exec("ROLLBACK"); @@ -471,8 +488,8 @@ export function insertDecision(d: Omit): void { throw new GSDError(GSD_STALE_STATE, "gsd-db: No database open"); currentDb .prepare( - `INSERT INTO decisions (id, when_context, scope, decision, choice, rationale, revisable, superseded_by) - VALUES (:id, :when_context, :scope, :decision, :choice, :rationale, :revisable, :superseded_by)`, + `INSERT INTO decisions (id, when_context, scope, decision, choice, rationale, revisable, made_by, superseded_by) + VALUES (:id, :when_context, :scope, :decision, :choice, :rationale, :revisable, :made_by, :superseded_by)`, ) .run({ ":id": d.id, @@ -482,6 +499,7 @@ export function insertDecision(d: Omit): void { ":choice": d.choice, ":rationale": d.rationale, ":revisable": d.revisable, + ":made_by": d.made_by ?? "agent", ":superseded_by": d.superseded_by, }); } @@ -502,6 +520,7 @@ export function getDecisionById(id: string): Decision | null { choice: row["choice"] as string, rationale: row["rationale"] as string, revisable: row["revisable"] as string, + made_by: (row["made_by"] as string as import("./types.js").DecisionMadeBy) ?? "agent", superseded_by: (row["superseded_by"] as string) ?? null, }; } @@ -521,6 +540,7 @@ export function getActiveDecisions(): Decision[] { choice: row["choice"] as string, rationale: row["rationale"] as string, revisable: row["revisable"] as string, + made_by: (row["made_by"] as string as import("./types.js").DecisionMadeBy) ?? "agent", superseded_by: null, })); } @@ -644,8 +664,8 @@ export function upsertDecision(d: Omit): void { throw new GSDError(GSD_STALE_STATE, "gsd-db: No database open"); currentDb .prepare( - `INSERT OR REPLACE INTO decisions (id, when_context, scope, decision, choice, rationale, revisable, superseded_by) - VALUES (:id, :when_context, :scope, :decision, :choice, :rationale, :revisable, :superseded_by)`, + `INSERT OR REPLACE INTO decisions (id, when_context, scope, decision, choice, rationale, revisable, made_by, superseded_by) + VALUES (:id, :when_context, :scope, :decision, :choice, :rationale, :revisable, :made_by, :superseded_by)`, ) .run({ ":id": d.id, @@ -655,6 +675,7 @@ export function upsertDecision(d: Omit): void { ":choice": d.choice, ":rationale": d.rationale, ":revisable": d.revisable, + ":made_by": d.made_by ?? "agent", ":superseded_by": d.superseded_by ?? null, }); } @@ -783,9 +804,15 @@ export function reconcileWorktreeDb( try { adapter.exec(`ATTACH DATABASE '${worktreeDbPath}' AS wt`); try { + // Check if attached wt database has the made_by column (legacy v3 worktrees won't) + const wtInfo = adapter.prepare("PRAGMA wt.table_info('decisions')").all(); + const hasMadeBy = wtInfo.some((col) => col["name"] === "made_by"); + const decConf = adapter .prepare( - `SELECT m.id FROM decisions m INNER JOIN wt.decisions w ON m.id = w.id WHERE m.decision != w.decision OR m.choice != w.choice OR m.rationale != w.rationale OR m.superseded_by IS NOT w.superseded_by`, + `SELECT m.id FROM decisions m INNER JOIN wt.decisions w ON m.id = w.id WHERE m.decision != w.decision OR m.choice != w.choice OR m.rationale != w.rationale OR ${ + hasMadeBy ? "m.made_by != w.made_by" : "'agent' != 'agent'" + } OR m.superseded_by IS NOT w.superseded_by`, ) .all(); for (const row of decConf) @@ -808,10 +835,12 @@ export function reconcileWorktreeDb( .prepare( ` INSERT OR REPLACE INTO decisions ( - id, when_context, scope, decision, choice, rationale, revisable, superseded_by + id, when_context, scope, decision, choice, rationale, revisable, made_by, superseded_by ) SELECT - id, when_context, scope, decision, choice, rationale, revisable, superseded_by + id, when_context, scope, decision, choice, rationale, revisable, ${ + hasMadeBy ? "made_by" : "'agent'" + }, superseded_by FROM wt.decisions `, ) diff --git a/src/resources/extensions/gsd/md-importer.ts b/src/resources/extensions/gsd/md-importer.ts index 29705a0c9..6a58e7e82 100644 --- a/src/resources/extensions/gsd/md-importer.ts +++ b/src/resources/extensions/gsd/md-importer.ts @@ -25,6 +25,8 @@ import { findMilestoneIds } from './guided-flow.js'; // ─── DECISIONS.md Parser ─────────────────────────────────────────────────── +const VALID_MADE_BY = new Set(['human', 'agent', 'collaborative']); + /** * Parse a DECISIONS.md markdown table into Decision objects (without seq). * Detects `(amends DXXX)` in the Decision column to build supersession info. @@ -64,6 +66,9 @@ export function parseDecisionsTable(content: string): Omit[] { const choice = cells[4].trim(); const rationale = cells[5].trim(); const revisable = cells[6].trim(); + // Made By column is optional for backward compatibility — defaults to 'agent' + const rawMadeBy = cells.length >= 8 ? cells[7].trim().toLowerCase() : 'agent'; + const made_by = (VALID_MADE_BY.has(rawMadeBy) ? rawMadeBy : 'agent') as import('./types.js').DecisionMadeBy; // Detect (amends DXXX) in the Decision column const amendsMatch = decisionText.match(/\(amends\s+(D\d+)\)/i); @@ -79,6 +84,7 @@ export function parseDecisionsTable(content: string): Omit[] { choice, rationale, revisable, + made_by, superseded_by: null, }); } diff --git a/src/resources/extensions/gsd/structured-data-formatter.ts b/src/resources/extensions/gsd/structured-data-formatter.ts index 20c3768eb..e8c6bf51c 100644 --- a/src/resources/extensions/gsd/structured-data-formatter.ts +++ b/src/resources/extensions/gsd/structured-data-formatter.ts @@ -25,6 +25,7 @@ interface DecisionInput { choice: string; rationale: string; revisable: string; + made_by?: string; } interface RequirementInput { @@ -61,6 +62,7 @@ export function formatDecisionCompact(decision: DecisionInput): string { decision.choice, decision.rationale, decision.revisable, + decision.made_by ?? 'agent', ].join(" | "); } @@ -70,7 +72,7 @@ export function formatDecisionsCompact(decisions: DecisionInput[]): string { return "# Decisions (compact)\n(none)"; } - const header = "# Decisions (compact)\nFields: id | when | scope | decision | choice | rationale | revisable"; + const header = "# Decisions (compact)\nFields: id | when | scope | decision | choice | rationale | revisable | made_by"; const lines = decisions.map(formatDecisionCompact); return `${header}\n\n${lines.join("\n")}`; } diff --git a/src/resources/extensions/gsd/templates/decisions.md b/src/resources/extensions/gsd/templates/decisions.md index d8e56d1ee..f8f44ee7c 100644 --- a/src/resources/extensions/gsd/templates/decisions.md +++ b/src/resources/extensions/gsd/templates/decisions.md @@ -4,5 +4,5 @@ To reverse a decision, add a new row that supersedes it. Read this file at the start of any planning or research phase. --> -| # | When | Scope | Decision | Choice | Rationale | Revisable? | -|---|------|-------|----------|--------|-----------|------------| +| # | When | Scope | Decision | Choice | Rationale | Revisable? | Made By | +|---|------|-------|----------|--------|-----------|------------|---------| diff --git a/src/resources/extensions/gsd/tests/context-store.test.ts b/src/resources/extensions/gsd/tests/context-store.test.ts index 0896e86c2..a3f256d91 100644 --- a/src/resources/extensions/gsd/tests/context-store.test.ts +++ b/src/resources/extensions/gsd/tests/context-store.test.ts @@ -51,17 +51,17 @@ console.log('\n=== context-store: query all active decisions ==='); insertDecision({ id: 'D001', when_context: 'M001/S01', scope: 'architecture', decision: 'use SQLite', choice: 'node:sqlite', rationale: 'built-in', - revisable: 'yes', superseded_by: 'D003', // superseded! + revisable: 'yes', made_by: 'agent', superseded_by: 'D003', // superseded! }); insertDecision({ id: 'D002', when_context: 'M001/S01', scope: 'architecture', decision: 'use WAL mode', choice: 'WAL', rationale: 'concurrent reads', - revisable: 'no', superseded_by: null, + revisable: 'no', made_by: 'agent', superseded_by: null, }); insertDecision({ id: 'D003', when_context: 'M002/S01', scope: 'performance', decision: 'use better-sqlite3', choice: 'better-sqlite3', rationale: 'faster', - revisable: 'yes', superseded_by: null, + revisable: 'yes', made_by: 'agent', superseded_by: null, }); const all = queryDecisions(); @@ -81,11 +81,13 @@ console.log('\n=== context-store: query decisions by milestone ==='); insertDecision({ id: 'D001', when_context: 'M001/S01', scope: 'architecture', decision: 'decision A', choice: 'A', rationale: 'r', revisable: 'yes', + made_by: 'agent', superseded_by: null, }); insertDecision({ id: 'D002', when_context: 'M002/S02', scope: 'architecture', decision: 'decision B', choice: 'B', rationale: 'r', revisable: 'yes', + made_by: 'agent', superseded_by: null, }); @@ -107,11 +109,13 @@ console.log('\n=== context-store: query decisions by scope ==='); insertDecision({ id: 'D001', when_context: 'M001/S01', scope: 'architecture', decision: 'decision A', choice: 'A', rationale: 'r', revisable: 'yes', + made_by: 'agent', superseded_by: null, }); insertDecision({ id: 'D002', when_context: 'M001/S01', scope: 'performance', decision: 'decision B', choice: 'B', rationale: 'r', revisable: 'yes', + made_by: 'agent', superseded_by: null, }); @@ -248,12 +252,12 @@ console.log('\n=== context-store: formatDecisionsForPrompt ==='); { seq: 1, id: 'D001', when_context: 'M001/S01', scope: 'architecture', decision: 'use SQLite', choice: 'node:sqlite', rationale: 'built-in', - revisable: 'yes', superseded_by: null, + revisable: 'yes', made_by: 'agent', superseded_by: null, }, { seq: 2, id: 'D002', when_context: 'M001/S02', scope: 'performance', decision: 'use WAL', choice: 'WAL', rationale: 'concurrent', - revisable: 'no', superseded_by: null, + revisable: 'no', made_by: 'human', superseded_by: null, }, ]); @@ -323,6 +327,7 @@ console.log('\n=== context-store: sub-5ms query timing ==='); choice: `choice ${i}`, rationale: `rationale ${i}`, revisable: i % 3 === 0 ? 'no' : 'yes', + made_by: 'agent', superseded_by: null, }); } diff --git a/src/resources/extensions/gsd/tests/db-writer.test.ts b/src/resources/extensions/gsd/tests/db-writer.test.ts index 44b5caac1..fbde354a0 100644 --- a/src/resources/extensions/gsd/tests/db-writer.test.ts +++ b/src/resources/extensions/gsd/tests/db-writer.test.ts @@ -59,6 +59,7 @@ const SAMPLE_DECISIONS: Decision[] = [ choice: 'better-sqlite3', rationale: 'Sync API', revisable: 'No', + made_by: 'collaborative', superseded_by: null, }, { @@ -70,6 +71,7 @@ const SAMPLE_DECISIONS: Decision[] = [ choice: '.gsd/gsd.db', rationale: 'Derived state', revisable: 'No', + made_by: 'agent', superseded_by: null, }, { @@ -81,6 +83,7 @@ const SAMPLE_DECISIONS: Decision[] = [ choice: 'node:sqlite fallback', rationale: 'Zero deps', revisable: 'Yes', + made_by: 'human', superseded_by: null, }, ]; @@ -166,6 +169,7 @@ console.log('\n── generateDecisionsMd round-trip ──'); assertEq(rt.choice, orig.choice, `decision ${orig.id} choice round-trips`); assertEq(rt.rationale, orig.rationale, `decision ${orig.id} rationale round-trips`); assertEq(rt.revisable, orig.revisable, `decision ${orig.id} revisable round-trips`); + assertEq(rt.made_by, orig.made_by, `decision ${orig.id} made_by round-trips`); } } @@ -177,6 +181,7 @@ console.log('\n── generateDecisionsMd format ──'); assertTrue(md.includes('