Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/application/context/decisions/DecisionView.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
* Read model for Decision aggregate.
* Represents the materialized view stored in SQLite.
* Represents the storage-agnostic projected state exposed by decision read ports.
*/
export interface DecisionView {
readonly decisionId: string;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { ShowDecisionRequest } from "./ShowDecisionRequest.js";
import { ShowDecisionResponse } from "./ShowDecisionResponse.js";

export interface IShowDecisionGateway {
showDecision(request: ShowDecisionRequest): Promise<ShowDecisionResponse>;
}
18 changes: 18 additions & 0 deletions src/application/context/decisions/show/LocalShowDecisionGateway.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { IDecisionViewReader } from "../get/IDecisionViewReader.js";
import { IShowDecisionGateway } from "./IShowDecisionGateway.js";
import { ShowDecisionRequest } from "./ShowDecisionRequest.js";
import { ShowDecisionResponse } from "./ShowDecisionResponse.js";

export class LocalShowDecisionGateway implements IShowDecisionGateway {
constructor(private readonly decisionViewReader: IDecisionViewReader) {}

async showDecision(request: ShowDecisionRequest): Promise<ShowDecisionResponse> {
const [decision] = await this.decisionViewReader.findByIds([request.decisionId]);

if (!decision) {
throw new Error(`Decision not found: ${request.decisionId}`);
}

return { decision };
}
}
11 changes: 11 additions & 0 deletions src/application/context/decisions/show/ShowDecisionController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { IShowDecisionGateway } from "./IShowDecisionGateway.js";
import { ShowDecisionRequest } from "./ShowDecisionRequest.js";
import { ShowDecisionResponse } from "./ShowDecisionResponse.js";

export class ShowDecisionController {
constructor(private readonly gateway: IShowDecisionGateway) {}

async handle(request: ShowDecisionRequest): Promise<ShowDecisionResponse> {
return this.gateway.showDecision(request);
}
}
3 changes: 3 additions & 0 deletions src/application/context/decisions/show/ShowDecisionRequest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export interface ShowDecisionRequest {
readonly decisionId: string;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { DecisionView } from "../DecisionView.js";

export interface ShowDecisionResponse {
readonly decision: DecisionView;
}
2 changes: 2 additions & 0 deletions src/application/host/IApplicationContainer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ import { ResumeWorkController } from "../context/work/resume/ResumeWorkControlle
import { AddDecisionController } from "../context/decisions/add/AddDecisionController.js";
import { GetDecisionsController } from "../context/decisions/get/GetDecisionsController.js";
import { SearchDecisionsController } from "../context/decisions/search/SearchDecisionsController.js";
import { ShowDecisionController } from "../context/decisions/show/ShowDecisionController.js";
import { ReverseDecisionController } from "../context/decisions/reverse/ReverseDecisionController.js";
import { RestoreDecisionController } from "../context/decisions/restore/RestoreDecisionController.js";
import { SupersedeDecisionController } from "../context/decisions/supersede/SupersedeDecisionController.js";
Expand Down Expand Up @@ -466,6 +467,7 @@ export interface IApplicationContainer {
addDecisionController: AddDecisionController;
getDecisionsController: GetDecisionsController;
searchDecisionsController: SearchDecisionsController;
showDecisionController: ShowDecisionController;
reverseDecisionController: ReverseDecisionController;
restoreDecisionController: RestoreDecisionController;
supersedeDecisionController: SupersedeDecisionController;
Expand Down
9 changes: 9 additions & 0 deletions src/infrastructure/host/HostBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ import { LocalGetDecisionsGateway } from "../../application/context/decisions/ge
import { GetDecisionsController } from "../../application/context/decisions/get/GetDecisionsController.js";
import { LocalSearchDecisionsGateway } from "../../application/context/decisions/search/LocalSearchDecisionsGateway.js";
import { SearchDecisionsController } from "../../application/context/decisions/search/SearchDecisionsController.js";
import { LocalShowDecisionGateway } from "../../application/context/decisions/show/LocalShowDecisionGateway.js";
import { ShowDecisionController } from "../../application/context/decisions/show/ShowDecisionController.js";
import { ReverseDecisionCommandHandler } from "../../application/context/decisions/reverse/ReverseDecisionCommandHandler.js";
import { LocalReverseDecisionGateway } from "../../application/context/decisions/reverse/LocalReverseDecisionGateway.js";
import { ReverseDecisionController } from "../../application/context/decisions/reverse/ReverseDecisionController.js";
Expand Down Expand Up @@ -1458,6 +1460,12 @@ const audiencePainContextReader = new SqliteAudiencePainContextReader(this.db);
const getDecisionsController = new GetDecisionsController(
getDecisionsGateway
);
const showDecisionGateway = new LocalShowDecisionGateway(
decisionViewReader
);
const showDecisionController = new ShowDecisionController(
showDecisionGateway
);
const searchDecisionsGateway = new LocalSearchDecisionsGateway(
decisionViewReader
);
Expand Down Expand Up @@ -2200,6 +2208,7 @@ const audiencePainContextReader = new SqliteAudiencePainContextReader(this.db);
// Decision Controllers
addDecisionController,
getDecisionsController,
showDecisionController,
searchDecisionsController,
reverseDecisionController,
restoreDecisionController,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import chalk from "chalk";
import { DecisionView } from "../../../../../application/context/decisions/DecisionView.js";
import { SemanticColors, TuiGlyphs } from "../../../../shared/DesignTokens.js";
import { TerminalOutput } from "../../../output/TerminalOutput.js";
import { TerminalOutputBuilder } from "../../../output/TerminalOutputBuilder.js";

const decisionShowStyle = {
heading: chalk.hex(SemanticColors.headline).bold,
label: chalk.hex(SemanticColors.label),
primary: chalk.hex(SemanticColors.primary),
muted: chalk.hex(SemanticColors.muted),
error: chalk.hex(SemanticColors.error),
} as const;

export class DecisionShowOutputBuilder {
private readonly builder = new TerminalOutputBuilder();

build(decision: DecisionView): TerminalOutput {
this.builder.reset();

const lines = [
"",
`${decisionShowStyle.heading(TuiGlyphs.accentBar)} ${decisionShowStyle.heading("Architectural Decision")}`,
this.field("ID", decision.decisionId),
this.field("Title", decision.title),
this.field("Status", decision.status),
this.field("Context", decision.context),
this.field("Rationale", decision.rationale),
this.alternatives(decision.alternatives),
this.field("Consequences", decision.consequences),
this.field("Superseded by", decision.supersededBy),
this.field("Reversal reason", decision.reversalReason),
this.field("Reversed at", decision.reversedAt),
this.field("Version", decision.version),
this.field("Created at", decision.createdAt),
this.field("Updated at", decision.updatedAt),
];

this.builder.addPrompt(lines.join("\n"));
return this.builder.build();
}

buildStructuredOutput(decision: DecisionView): TerminalOutput {
this.builder.reset();
this.builder.addData(decision);
return this.builder.build();
}

buildNotFoundError(decisionId: string): TerminalOutput {
this.builder.reset();
this.builder.addPrompt(
`${decisionShowStyle.error(TuiGlyphs.cross)} ${decisionShowStyle.error("Decision not found")}\n` +
`${decisionShowStyle.muted(`No decision exists with ID: ${decisionId}`)}`
);
return this.builder.build();
}

buildFailureError(error: Error | string): TerminalOutput {
this.builder.reset();
const details = error instanceof Error ? error.message : error;
this.builder.addPrompt(
`${decisionShowStyle.error(TuiGlyphs.cross)} ${decisionShowStyle.error("Failed to show decision")}\n` +
decisionShowStyle.muted(details)
);
return this.builder.build();
}

private field(label: string, value: string | number | null): string {
const rendered = value === null
? decisionShowStyle.muted("(null)")
: value === ""
? decisionShowStyle.muted("(empty)")
: decisionShowStyle.primary(String(value));

return `${decisionShowStyle.label(`${label}:`)} ${rendered}`;
}

private alternatives(values: readonly string[]): string {
const label = decisionShowStyle.label("Alternatives:");
if (values.length === 0) {
return `${label} ${decisionShowStyle.muted("(empty)")}`;
}

return `${label}\n${values
.map((value) => ` ${decisionShowStyle.muted(TuiGlyphs.bullet)} ${decisionShowStyle.primary(value === "" ? "(empty)" : value)}`)
.join("\n")}`;
}
}
63 changes: 63 additions & 0 deletions src/presentation/cli/commands/decisions/show/decision.show.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { IApplicationContainer } from "../../../../../application/host/IApplicationContainer.js";
import { Renderer } from "../../../rendering/Renderer.js";
import { RenderData } from "../../../rendering/types.js";
import { CommandMetadata } from "../../registry/CommandMetadata.js";
import { DecisionShowOutputBuilder } from "./DecisionShowOutputBuilder.js";

export const metadata: CommandMetadata = {
description: "Display the complete architectural decision record",
category: "solution",
requiredOptions: [
{
flags: "-i, --id <decisionId>",
description: "ID of the decision to show",
},
],
examples: [
{
command: "jumbo decision show --id dec_abc123",
description: "Show the complete decision record",
},
{
command: "jumbo decision show --id dec_abc123 --format json",
description: "Show the complete decision record as JSON",
},
],
related: ["decisions list", "decisions search", "decision update"],
requiresProject: true,
};

export async function decisionShow(
options: { id: string },
container: IApplicationContainer
): Promise<void> {
const renderer = Renderer.getInstance();
const outputBuilder = new DecisionShowOutputBuilder();

try {
const { decision } = await container.showDecisionController.handle({
decisionId: options.id,
});

if (renderer.getConfig().format === "text") {
renderer.info(outputBuilder.build(decision).toHumanReadable());
return;
}

const dataSection = outputBuilder
.buildStructuredOutput(decision)
.getSections()
.find((section) => section.type === "data");

if (dataSection) {
renderer.data(dataSection.content as RenderData);
}
} catch (error) {
const output = error instanceof Error && error.message === `Decision not found: ${options.id}`
? outputBuilder.buildNotFoundError(options.id)
: outputBuilder.buildFailureError(error instanceof Error ? error : String(error));

renderer.error(output.toHumanReadable());
process.exitCode = 1;
}
}
6 changes: 6 additions & 0 deletions src/presentation/cli/commands/registry/generated-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { decisionsList, metadata as decisionsListMeta } from '../../commands/dec
import { decisionRestore, metadata as decisionRestoreMeta } from '../../commands/decisions/restore/decision.restore.js';
import { decisionReverse, metadata as decisionReverseMeta } from '../../commands/decisions/reverse/decision.reverse.js';
import { decisionsSearch, metadata as decisionsSearchMeta } from '../../commands/decisions/search/decisions.search.js';
import { decisionShow, metadata as decisionShowMeta } from '../../commands/decisions/show/decision.show.js';
import { decisionSupersede, metadata as decisionSupersedeMeta } from '../../commands/decisions/supersede/decision.supersede.js';
import { decisionUpdate, metadata as decisionUpdateMeta } from '../../commands/decisions/update/decision.update.js';
import { dependencyAdd, metadata as dependencyAddMeta } from '../../commands/dependencies/add/dependency.add.js';
Expand Down Expand Up @@ -222,6 +223,11 @@ export const commands: RegisteredCommand[] = [
metadata: decisionsSearchMeta,
handler: decisionsSearch
},
{
path: 'decision show',
metadata: decisionShowMeta,
handler: decisionShow
},
{
path: 'decision supersede',
metadata: decisionSupersedeMeta,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { beforeEach, describe, expect, it, jest } from "@jest/globals";
import { DecisionView } from "../../../../../src/application/context/decisions/DecisionView.js";
import { IDecisionViewReader } from "../../../../../src/application/context/decisions/get/IDecisionViewReader.js";
import { LocalShowDecisionGateway } from "../../../../../src/application/context/decisions/show/LocalShowDecisionGateway.js";

describe("LocalShowDecisionGateway", () => {
let decisionViewReader: jest.Mocked<IDecisionViewReader>;
let gateway: LocalShowDecisionGateway;

const decision: DecisionView = {
decisionId: "dec_123",
title: "Use event sourcing",
context: "State changes need a durable audit trail.",
rationale: "Events preserve intent.",
alternatives: ["CRUD snapshots", "Change data capture"],
consequences: "Projection rebuilding is required.",
status: "active",
supersededBy: null,
reversalReason: null,
reversedAt: null,
version: 3,
createdAt: "2026-01-01T10:00:00.000Z",
updatedAt: "2026-02-01T11:00:00.000Z",
};

beforeEach(() => {
decisionViewReader = {
findAll: jest.fn(),
findByIds: jest.fn(),
search: jest.fn(),
};
gateway = new LocalShowDecisionGateway(decisionViewReader);
});

it("retrieves one decision through findByIds", async () => {
decisionViewReader.findByIds.mockResolvedValue([decision]);

await expect(gateway.showDecision({ decisionId: "dec_123" })).resolves.toEqual({ decision });
expect(decisionViewReader.findByIds).toHaveBeenCalledWith(["dec_123"]);
expect(decisionViewReader.findAll).not.toHaveBeenCalled();
expect(decisionViewReader.search).not.toHaveBeenCalled();
});

it("fails clearly when the decision ID is unknown", async () => {
decisionViewReader.findByIds.mockResolvedValue([]);

await expect(gateway.showDecision({ decisionId: "dec_missing" }))
.rejects.toThrow("Decision not found: dec_missing");
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { describe, expect, it, jest } from "@jest/globals";
import { DecisionView } from "../../../../../src/application/context/decisions/DecisionView.js";
import { IShowDecisionGateway } from "../../../../../src/application/context/decisions/show/IShowDecisionGateway.js";
import { ShowDecisionController } from "../../../../../src/application/context/decisions/show/ShowDecisionController.js";

describe("ShowDecisionController", () => {
it("delegates the typed request and returns the gateway response", async () => {
const decision: DecisionView = {
decisionId: "dec_123",
title: "Use event sourcing",
context: "Auditability is required.",
rationale: null,
alternatives: [],
consequences: null,
status: "active",
supersededBy: null,
reversalReason: null,
reversedAt: null,
version: 1,
createdAt: "2026-01-01T10:00:00.000Z",
updatedAt: "2026-01-01T10:00:00.000Z",
};
const gateway: jest.Mocked<IShowDecisionGateway> = {
showDecision: jest.fn().mockResolvedValue({ decision }),
};
const controller = new ShowDecisionController(gateway);

await expect(controller.handle({ decisionId: "dec_123" })).resolves.toEqual({ decision });
expect(gateway.showDecision).toHaveBeenCalledWith({ decisionId: "dec_123" });
});
});
9 changes: 9 additions & 0 deletions tests/infrastructure/host/HostBuilder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, beforeEach, describe, expect, it, jest } from "@jest/globals";
import { AuditRelationsController } from "../../../src/application/context/relations/audit/AuditRelationsController.js";
import { ShowDecisionController } from "../../../src/application/context/decisions/show/ShowDecisionController.js";
import { HostBuilder } from "../../../src/infrastructure/host/HostBuilder.js";
import { SqliteRelationNodeCatalog } from "../../../src/infrastructure/context/relations/audit/SqliteRelationNodeCatalog.js";
import { MigrationRunner } from "../../../src/infrastructure/persistence/MigrationRunner.js";
Expand Down Expand Up @@ -46,4 +47,12 @@ describe("HostBuilder relation audit wiring", () => {
}),
);
});

it("exposes a usable decision-show controller backed by the decision view reader", async () => {
const container = await new HostBuilder(tempDirectory, db).build();

expect(container.showDecisionController).toBeInstanceOf(ShowDecisionController);
await expect(container.showDecisionController.handle({ decisionId: "dec_missing" }))
.rejects.toThrow("Decision not found: dec_missing");
});
});
Loading
Loading