feat: ADR attribution — distinguish human vs agent vs collaborative decisions (#1830)

* 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
This commit is contained in:
Jeremy McSpadden 2026-03-21 16:26:28 -05:00 committed by GitHub
parent f4db25b9b8
commit c1a35dd1b3
17 changed files with 142 additions and 30 deletions

View file

@ -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(),
);

View file

@ -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');

View file

@ -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,
}));
}

View file

@ -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<Decision, "seq">): 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<Decision, "seq">): 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<Decision, "seq">): 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<Decision, "seq">): 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
`,
)

View file

@ -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<Decision, 'seq'>[] {
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<Decision, 'seq'>[] {
choice,
rationale,
revisable,
made_by,
superseded_by: null,
});
}

View file

@ -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")}`;
}

View file

@ -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 |
|---|------|-------|----------|--------|-----------|------------|---------|

View file

@ -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,
});
}

View file

@ -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('<!-- Append-only'), 'contains HTML comment block');
assertTrue(md.includes('| # | When | Scope'), 'contains table header');
assertTrue(md.includes('|---|------|-------'), 'contains separator row');
assertTrue(md.includes('| Made By |'), 'contains Made By column header');
}
console.log('\n── generateDecisionsMd empty input ──');
@ -200,6 +205,7 @@ console.log('\n── generateDecisionsMd pipe escaping ──');
choice: 'A',
rationale: 'Better',
revisable: 'No',
made_by: 'agent',
superseded_by: null,
};
const md = generateDecisionsMd([withPipe]);
@ -291,6 +297,7 @@ console.log('\n── nextDecisionId ──');
choice: 'test choice',
rationale: 'test',
revisable: 'No',
made_by: 'agent',
superseded_by: null,
});
upsertDecision({
@ -301,6 +308,7 @@ console.log('\n── nextDecisionId ──');
choice: 'test choice',
rationale: 'test',
revisable: 'No',
made_by: 'agent',
superseded_by: null,
});
@ -520,6 +528,7 @@ console.log('\n── Full DB round-trip: decisions ──');
choice: d.choice,
rationale: d.rationale,
revisable: d.revisable,
made_by: d.made_by,
superseded_by: d.superseded_by,
});
}
@ -536,6 +545,7 @@ console.log('\n── Full DB round-trip: decisions ──');
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,
}));

View file

@ -66,7 +66,7 @@ console.log('\n=== gsd-db: fresh DB schema init (memory) ===');
// Check schema_version table
const adapter = _getAdapter()!;
const version = adapter.prepare('SELECT MAX(version) as version FROM schema_version').get();
assertEq(version?.['version'], 3, 'schema version should be 3');
assertEq(version?.['version'], 4, 'schema version should be 4');
// Check tables exist by querying them
const dRows = adapter.prepare('SELECT count(*) as cnt FROM decisions').get();
@ -93,6 +93,7 @@ console.log('\n=== gsd-db: double-init idempotency ===');
choice: 'option A',
rationale: 'because',
revisable: 'yes',
made_by: 'agent',
superseded_by: null,
});
@ -123,6 +124,7 @@ console.log('\n=== gsd-db: insert + get decision ===');
choice: 'node:sqlite',
rationale: 'built-in, zero deps',
revisable: 'yes, if perf insufficient',
made_by: 'agent',
superseded_by: null,
});
@ -186,6 +188,7 @@ console.log('\n=== gsd-db: active_decisions view excludes superseded ===');
choice: 'JSON',
rationale: 'simple',
revisable: 'yes',
made_by: 'agent',
superseded_by: 'D002', // superseded!
});
@ -197,6 +200,7 @@ console.log('\n=== gsd-db: active_decisions view excludes superseded ===');
choice: 'SQLite',
rationale: 'better querying',
revisable: 'yes',
made_by: 'agent',
superseded_by: null, // active
});
@ -208,6 +212,7 @@ console.log('\n=== gsd-db: active_decisions view excludes superseded ===');
choice: 'WAL',
rationale: 'concurrent reads',
revisable: 'no',
made_by: 'agent',
superseded_by: null, // active
});
@ -294,6 +299,7 @@ console.log('\n=== gsd-db: transaction rollback on error ===');
choice: 'test',
rationale: 'test',
revisable: 'test',
made_by: 'agent',
superseded_by: null,
});
@ -309,6 +315,7 @@ console.log('\n=== gsd-db: transaction rollback on error ===');
choice: 'test',
rationale: 'test',
revisable: 'test',
made_by: 'agent',
superseded_by: null,
});
throw new Error('intentional failure');

View file

@ -187,6 +187,36 @@ console.log('=== md-importer: malformed/empty rows skipped ===');
assertEq(decisions[1].id, 'D003', 'second valid row (skipping malformed)');
}
console.log('=== md-importer: made_by backward compatibility (old 7-column format) ===');
{
const decisions = parseDecisionsTable(DECISIONS_MD);
// Old format has no Made By column — should default to 'agent'
for (const d of decisions) {
assertEq(d.made_by, 'agent', `${d.id} made_by defaults to agent for legacy format`);
}
}
console.log('=== md-importer: made_by column parsing (new 8-column format) ===');
{
const newFormatMd = `# Decisions Register
| # | When | Scope | Decision | Choice | Rationale | Revisable? | Made By |
|---|------|-------|----------|--------|-----------|------------|---------|
| D001 | M001 | library | SQLite library | better-sqlite3 | Sync API | No | human |
| D002 | M001 | arch | DB location | .gsd/gsd.db | Derived state | No | agent |
| D003 | M002 | impl | Config format | JSON | Simple | Yes | collaborative |
| D004 | M002 | impl | Cache strategy | LRU | Predictable | No | bogus |
`;
const decisions = parseDecisionsTable(newFormatMd);
assertEq(decisions.length, 4, 'should parse 4 decisions with new format');
assertEq(decisions[0].made_by, 'human', 'D001 made_by = human');
assertEq(decisions[1].made_by, 'agent', 'D002 made_by = agent');
assertEq(decisions[2].made_by, 'collaborative', 'D003 made_by = collaborative');
assertEq(decisions[3].made_by, 'agent', 'D004 invalid made_by defaults to agent');
}
// ═══════════════════════════════════════════════════════════════════════════
// md-importer: parseRequirementsSections
// ═══════════════════════════════════════════════════════════════════════════
@ -354,7 +384,7 @@ console.log('=== md-importer: schema v1→v2 migration ===');
openDatabase(':memory:');
const adapter = _getAdapter();
const version = adapter?.prepare('SELECT MAX(version) as v FROM schema_version').get();
assertEq(version?.v, 3, 'new DB should be at schema version 3');
assertEq(version?.v, 4, 'new DB should be at schema version 4');
// Artifacts table should exist
const tableCheck = adapter?.prepare("SELECT count(*) as c FROM sqlite_master WHERE type='table' AND name='artifacts'").get();

View file

@ -335,9 +335,9 @@ console.log('\n=== memory-store: schema includes memories table ===');
const viewCount = adapter.prepare('SELECT count(*) as cnt FROM active_memories').get();
assertEq(viewCount?.['cnt'], 0, 'active_memories view should exist');
// Verify schema version is 3
// Verify schema version is 4
const version = adapter.prepare('SELECT MAX(version) as v FROM schema_version').get();
assertEq(version?.['v'], 3, 'schema version should be 3');
assertEq(version?.['v'], 4, 'schema version should be 4');
closeDatabase();
}

View file

@ -43,6 +43,7 @@ console.log('\n=== prompt-db: scoped decisions from DB ===');
choice: `choice ${i}`,
rationale: `rationale ${i}`,
revisable: 'yes',
made_by: 'agent',
superseded_by: null,
});
}
@ -201,6 +202,7 @@ console.log('\n=== prompt-db: scoped filtering reduces content ===');
choice: `choice ${i}`,
rationale: `rationale ${i} with additional context`,
revisable: 'yes',
made_by: 'agent',
superseded_by: null,
});
}
@ -269,7 +271,7 @@ console.log('\n=== prompt-db: DB helpers wrapper format matches expected pattern
insertDecision({
id: 'D001', when_context: 'M001/S01', scope: 'architecture',
decision: 'use SQLite', choice: 'better-sqlite3', rationale: 'fast',
revisable: 'yes', superseded_by: null,
revisable: 'yes', made_by: 'agent', superseded_by: null,
});
insertRequirement({

View file

@ -86,16 +86,17 @@ describe("structured-data-formatter: formatDecisionCompact", () => {
const result = formatDecisionCompact(sampleDecision);
assert.equal(
result,
"D001 | M001/S01 | architecture | Use SQLite for storage | WAL mode, single-writer | Built-in, no external deps | yes",
"D001 | M001/S01 | architecture | Use SQLite for storage | WAL mode, single-writer | Built-in, no external deps | yes | agent",
);
});
it("includes all fields in the correct order", () => {
const result = formatDecisionCompact(sampleDecision);
const parts = result.split(" | ");
assert.equal(parts.length, 7);
assert.equal(parts.length, 8);
assert.equal(parts[0], "D001");
assert.equal(parts[6], "yes");
assert.equal(parts[7], "agent");
});
});
@ -107,7 +108,7 @@ describe("structured-data-formatter: formatDecisionsCompact", () => {
it("includes Fields header line", () => {
const result = formatDecisionsCompact([sampleDecision]);
assert.ok(result.startsWith("# Decisions (compact)"));
assert.ok(result.includes("Fields: id | when | scope | decision | choice | rationale | revisable"));
assert.ok(result.includes("Fields: id | when | scope | decision | choice | rationale | revisable | made_by"));
});
it("formats multiple decisions on separate lines", () => {

View file

@ -138,6 +138,7 @@ async function main(): Promise<void> {
choice: "reconcile on merge",
rationale: "test coverage",
revisable: "no",
made_by: 'agent',
superseded_by: null,
});
closeDatabase();

View file

@ -47,6 +47,7 @@ function seedMainDb(dbPath: string): void {
choice: 'node:sqlite',
rationale: 'Built-in',
revisable: 'yes',
made_by: 'agent',
superseded_by: null,
});
insertRequirement({
@ -182,6 +183,7 @@ console.log('\n=== worktree-db: reconcileWorktreeDb ===');
choice: 'WAL',
rationale: 'Performance',
revisable: 'yes',
made_by: 'agent',
superseded_by: null,
});
closeDatabase();
@ -357,6 +359,7 @@ console.log('\n=== worktree-db: reconcileWorktreeDb ===');
choice: 'yes',
rationale: 'Robustness',
revisable: 'no',
made_by: 'agent',
superseded_by: null,
});
closeDatabase();
@ -395,6 +398,7 @@ console.log('\n=== worktree-db: reconcileWorktreeDb ===');
choice: 'works',
rationale: 'Verify DETACH cleanup',
revisable: 'no',
made_by: 'agent',
superseded_by: null,
});

View file

@ -404,6 +404,8 @@ export interface HookStatusEntry {
// ─── Database Types (Decisions & Requirements) ────────────────────────────
export type DecisionMadeBy = "human" | "agent" | "collaborative";
export interface Decision {
seq: number; // auto-increment primary key
id: string; // e.g. "D001"
@ -413,6 +415,7 @@ export interface Decision {
choice: string; // the specific choice made
rationale: string; // why this choice
revisable: string; // whether/when revisable
made_by: DecisionMadeBy; // who made the decision: human, agent, or collaborative
superseded_by: string | null; // ID of superseding decision, or null
}