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:
parent
f4db25b9b8
commit
c1a35dd1b3
17 changed files with 142 additions and 30 deletions
|
|
@ -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(),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
`,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|---|------|-------|----------|--------|-----------|------------|---------|
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue