diff --git a/packages/core/src/application/BatchService.ts b/packages/core/src/application/BatchService.ts index 9d5ca7e..e77fa5c 100644 --- a/packages/core/src/application/BatchService.ts +++ b/packages/core/src/application/BatchService.ts @@ -482,7 +482,7 @@ export class BatchService { ]) return { proposals: new Set(proposals.map(proposal => proposal.id)), - clarifications: new Set(clarifications.map(ticket => ticket.id)), + clarifications: new Set(clarifications.map(clarification => clarification.id)), } } diff --git a/packages/core/src/application/HITLService.ts b/packages/core/src/application/HITLService.ts index a0951de..87af765 100644 --- a/packages/core/src/application/HITLService.ts +++ b/packages/core/src/application/HITLService.ts @@ -103,7 +103,7 @@ export class HITLService { // Candidates are only validated at answer time, since each picks a different op set. async submitClarification(draft: ClarificationCreate & { submitterId?: UserId }): Promise { const submitter = draft.submitterId ? await this.userDirectory.resolve(draft.submitterId) : null - const ticket = new Clarification({ + const clarification = new Clarification({ id: newClarificationId(), workspaceId: draft.workspaceId, question: draft.question, @@ -119,20 +119,20 @@ export class HITLService { ...(submitter?.kind ? { ownerKind: submitter.kind } : {}), }) return this.withLockedWorkspace(draft.workspaceId, async (workspace) => { - await this.deps.clarificationRepository.save(ticket) + await this.deps.clarificationRepository.save(clarification) await this.commitWorkspaceChange(workspace, { kind: 'clarification-submit', - subject: `submitted ${ticket.id}`, + subject: `submitted ${clarification.id}`, userId: SUBMIT_USER_ID, - clarificationId: ticket.id, + clarificationId: clarification.id, }) this.deps.eventBus?.publish({ type: 'clarification.created', - workspaceId: ticket.workspaceId, - ticketId: ticket.id, + workspaceId: clarification.workspaceId, + clarificationId: clarification.id, at: this.deps.clock.now(), }) - return ticket + return clarification }) } @@ -191,7 +191,7 @@ export class HITLService { note?: string }): Promise { const { clarificationId, selection, userId, note } = options - let ticket = await this.deps.clarificationRepository.load(clarificationId) + let clarification = await this.deps.clarificationRepository.load(clarificationId) let candidateId: ClarificationCandidateId if (selection.kind === 'existing') { candidateId = selection.candidateId @@ -203,14 +203,14 @@ export class HITLService { sourceReferences: [], proposedOperations: [], } - ticket = ticket.appendCandidate(newCandidate) + clarification = clarification.appendCandidate(newCandidate) candidateId = newCandidate.id } - const operations = [...ticket.resolveCandidate(candidateId)] - await this.assertOperationsValid(ticket.workspaceId, operations) + const operations = [...clarification.resolveCandidate(candidateId)] + await this.assertOperationsValid(clarification.workspaceId, operations) - return this.withLockedWorkspace(ticket.workspaceId, async (workspace) => { - const answered = ticket.markAnswered(candidateId, userId) + return this.withLockedWorkspace(clarification.workspaceId, async (workspace) => { + const answered = clarification.markAnswered(candidateId, userId) await this.deps.clarificationRepository.save(answered) await this.commitWorkspaceChange(workspace, { kind: 'clarification-answer', @@ -220,8 +220,8 @@ export class HITLService { }) this.deps.eventBus?.publish({ type: 'clarification.answered', - workspaceId: ticket.workspaceId, - ticketId: ticket.id, + workspaceId: clarification.workspaceId, + clarificationId: clarification.id, at: this.deps.clock.now(), }) return answered @@ -234,9 +234,9 @@ export class HITLService { userId: UserId, proposalId?: ProposalId, ): Promise { - const ticket = await this.deps.clarificationRepository.load(clarificationId) - return this.withLockedWorkspace(ticket.workspaceId, async (workspace) => { - const applied = ticket.markApplied(proposalId) + const clarification = await this.deps.clarificationRepository.load(clarificationId) + return this.withLockedWorkspace(clarification.workspaceId, async (workspace) => { + const applied = clarification.markApplied(proposalId) await this.deps.clarificationRepository.save(applied) await this.commitWorkspaceChange(workspace, { kind: 'clarification-apply', @@ -247,8 +247,8 @@ export class HITLService { }) this.deps.eventBus?.publish({ type: 'clarification.applied', - workspaceId: ticket.workspaceId, - ticketId: ticket.id, + workspaceId: clarification.workspaceId, + clarificationId: clarification.id, ...(proposalId ? { proposalId } : {}), at: this.deps.clock.now(), }) @@ -261,9 +261,9 @@ export class HITLService { reason: string, userId: UserId, ): Promise { - const ticket = await this.deps.clarificationRepository.load(clarificationId) - return this.withLockedWorkspace(ticket.workspaceId, async (workspace) => { - const skipped = ticket.markSkipped(userId) + const clarification = await this.deps.clarificationRepository.load(clarificationId) + return this.withLockedWorkspace(clarification.workspaceId, async (workspace) => { + const skipped = clarification.markSkipped(userId) await this.deps.clarificationRepository.save(skipped) await this.commitWorkspaceChange(workspace, { kind: 'clarification-skip', @@ -273,8 +273,8 @@ export class HITLService { }) this.deps.eventBus?.publish({ type: 'clarification.skipped', - workspaceId: ticket.workspaceId, - ticketId: ticket.id, + workspaceId: clarification.workspaceId, + clarificationId: clarification.id, at: this.deps.clock.now(), }) return skipped diff --git a/packages/core/src/domain/hitl/Clarification.ts b/packages/core/src/domain/hitl/Clarification.ts index 22fc192..92e57ce 100644 --- a/packages/core/src/domain/hitl/Clarification.ts +++ b/packages/core/src/domain/hitl/Clarification.ts @@ -14,11 +14,11 @@ import type { import { ConflictError, NotFoundError } from '../errors.js' /** - * Lifecycle. From pending a ticket is either answered then applied, or skipped. + * Lifecycle. From pending a clarification is either answered then applied, or skipped. * * `markAnswered` records the user's choice but does NOT mutate the graph, - * the resolution is snapshotted onto the ticket and the ddd:clarify skill is expected to wrap it into a Proposal. - * Once that Proposal lands the ticket transitions to `applied` via `markApplied`, + * the resolution is snapshotted onto the clarification and the ddd:clarify skill is expected to wrap it into a Proposal. + * Once that Proposal lands the clarification transitions to `applied` via `markApplied`, * which only stamps the (optional) proposalId, the actual graph mutation happens inside `HITLService.applyProposal`. * proposalId is omitted when the chosen candidate's resolution had no graph impact, so no Proposal was produced. * @@ -44,22 +44,22 @@ export class Clarification { resolveCandidate(candidateId: ClarificationCandidateId): readonly GraphOperation[] { const match = this.data.candidates.find(candidate => candidate.id === candidateId) if (!match) { - throw new NotFoundError(`Candidate "${candidateId}" not in ticket "${this.data.id}"`) + throw new NotFoundError(`Candidate "${candidateId}" not in clarification "${this.data.id}"`) } return match.proposedOperations } /** - * Append a candidate to a pending ticket. + * Append a candidate to a pending clarification. * Used when the reviewer's actual answer doesn't match any of the skill-supplied options and they author one inline. - * Refuses on non-pending tickets so an already-answered ticket can't grow new options retroactively, + * Refuses on non-pending clarifications so an already-answered clarification can't grow new options retroactively, * and rejects duplicate ids to keep `resolveCandidate` deterministic. */ appendCandidate(candidate: ClarificationCandidate): Clarification { this.requireStatus('pending') if (this.data.candidates.some(existingCandidate => existingCandidate.id === candidate.id)) { throw new ConflictError( - `Candidate "${candidate.id}" already exists on ticket "${this.data.id}"`, + `Candidate "${candidate.id}" already exists on clarification "${this.data.id}"`, ) } return new Clarification({ @@ -104,7 +104,7 @@ export class Clarification { private requireStatus(expectedStatus: ClarificationStatus): void { if (this.data.status !== expectedStatus) { - throw new ConflictError(`Clarification ticket "${this.data.id}" is ${this.data.status}, not ${expectedStatus}`) + throw new ConflictError(`Clarification "${this.data.id}" is ${this.data.status}, not ${expectedStatus}`) } } } diff --git a/packages/core/src/domain/plugin/OntologyPlugin.ts b/packages/core/src/domain/plugin/OntologyPlugin.ts index 2fe21e6..42f80c1 100644 --- a/packages/core/src/domain/plugin/OntologyPlugin.ts +++ b/packages/core/src/domain/plugin/OntologyPlugin.ts @@ -1,4 +1,4 @@ -import type { BatchUnit, EdgeTypeId, ModelSnapshot, NodeStatus, NodeTypeId, OntologyId, SkillId, SourceRole, ValidationIssue } from '@braidhq/schema' +import type { BatchUnit, EdgeTypeId, LocalizedText, ModelSnapshot, NodeStatus, NodeTypeId, OntologyId, SkillId, SourceRole, ValidationIssue } from '@braidhq/schema' import type { Plugin } from './Plugin.js' /** @@ -8,7 +8,7 @@ import type { Plugin } from './Plugin.js' */ export interface SourceRoleDescriptor { readonly id: SourceRole - readonly label: string + readonly label: LocalizedText /** Sources of this role must be present for the ontology to run. */ readonly required?: boolean /** Sources of this role enumerate into batch units, and their sync drives the Reactor. */ @@ -19,7 +19,7 @@ export interface SourceRoleDescriptor { export interface NodeTypeDescriptor { readonly id: NodeTypeId - readonly label: string + readonly label: LocalizedText readonly description?: string readonly allowedStatuses?: readonly NodeStatus[] /** @@ -57,7 +57,7 @@ export interface NodeTypeDescriptor { export interface EdgeTypeDescriptor { readonly id: EdgeTypeId - readonly label?: string + readonly label?: LocalizedText /** * Short prose explaining what this edge means and when to emit it. * Surfaced to LLMs through the `/ontology` API, diff --git a/packages/core/src/domain/users/UserDirectory.ts b/packages/core/src/domain/users/UserDirectory.ts index fa05573..f483c1f 100644 --- a/packages/core/src/domain/users/UserDirectory.ts +++ b/packages/core/src/domain/users/UserDirectory.ts @@ -17,7 +17,7 @@ export interface UserDirectory { export interface UserAuthor { readonly displayName: string readonly email?: string - // Snapshotted onto proposals and tickets at submit time, + // Snapshotted onto proposals and clarifications at submit time, // so the HITL views classify an autonomous owner without a read-time lookup. readonly kind?: UserKind } diff --git a/packages/core/src/infrastructure/in-memory/InMemoryClarificationRepository.ts b/packages/core/src/infrastructure/in-memory/InMemoryClarificationRepository.ts index 5a4e4df..d5c6775 100644 --- a/packages/core/src/infrastructure/in-memory/InMemoryClarificationRepository.ts +++ b/packages/core/src/infrastructure/in-memory/InMemoryClarificationRepository.ts @@ -8,30 +8,30 @@ export class InMemoryClarificationRepository implements ClarificationRepository private readonly store = new InMemoryKeyedStore('Clarification') async list(filter?: ClarificationFilter): Promise { - let tickets = this.store.listAll() + let clarifications = this.store.listAll() if (filter?.workspaceId !== undefined) { const wsId = filter.workspaceId - tickets = tickets.filter(ticket => ticket.workspaceId === wsId) + clarifications = clarifications.filter(clarification => clarification.workspaceId === wsId) } if (filter?.statuses && filter.statuses.length > 0) { const statuses = filter.statuses - tickets = tickets.filter(ticket => statuses.includes(ticket.status)) + clarifications = clarifications.filter(clarification => statuses.includes(clarification.status)) } if (filter?.viewerId !== undefined) { const viewerId = filter.viewerId const includeServiceOwned = filter.includeServiceOwned ?? false - tickets = tickets.filter(ticket => - ticket.status !== 'pending' || ticket.owner === viewerId || (includeServiceOwned && ticket.ownerKind === 'service'), + clarifications = clarifications.filter(clarification => + clarification.status !== 'pending' || clarification.owner === viewerId || (includeServiceOwned && clarification.ownerKind === 'service'), ) } - return paginate(tickets, filter?.limit, filter?.offset) + return paginate(clarifications, filter?.limit, filter?.offset) } async load(clarificationId: ClarificationId): Promise { return this.store.get(clarificationId) } - async save(ticket: Clarification): Promise { - this.store.set(ticket.id, ticket) + async save(clarification: Clarification): Promise { + this.store.set(clarification.id, clarification) } } diff --git a/packages/core/test/application/HITLService.test.ts b/packages/core/test/application/HITLService.test.ts index 1213765..50e65dd 100644 --- a/packages/core/test/application/HITLService.test.ts +++ b/packages/core/test/application/HITLService.test.ts @@ -197,19 +197,19 @@ describe('HITLService', () => { }) describe('skipClarification', () => { - it('marks ticket as skipped', async () => { + it('marks clarification as skipped', async () => { const fixture = await setupFixture() - const ticket = makeClarification(fixture.workspaceId) - await fixture.clarificationRepository.save(ticket) + const clarification = makeClarification(fixture.workspaceId) + await fixture.clarificationRepository.save(clarification) const skipped = await fixture.service.skipClarification( - ticket.id, + clarification.id, 'out of scope for this milestone', userId, ) expect(skipped.status).toBe('skipped') - const reloaded = await fixture.clarificationRepository.load(ticket.id) + const reloaded = await fixture.clarificationRepository.load(clarification.id) expect(reloaded.status).toBe('skipped') }) }) @@ -227,7 +227,7 @@ describe('HITLService', () => { }, ]) const candidateId = mintTestId('cc') as ClarificationCandidateId - const ticket = makeClarification(fixture.workspaceId, { + const clarification = makeClarification(fixture.workspaceId, { candidates: [{ id: candidateId, description: 'yes', @@ -235,10 +235,10 @@ describe('HITLService', () => { proposedOperations: [{ operation: 'removeNode', nodeId }], }], }) - await fixture.clarificationRepository.save(ticket) + await fixture.clarificationRepository.save(clarification) const answered = await fixture.service.answerClarification({ - clarificationId: ticket.id, + clarificationId: clarification.id, selection: { kind: 'existing', candidateId }, userId, }) @@ -248,7 +248,7 @@ describe('HITLService', () => { const snapshot = await fixture.modelRepository.load(fixture.workspaceId) expect(snapshot.nodes).toHaveLength(1) - const reloaded = await fixture.clarificationRepository.load(ticket.id) + const reloaded = await fixture.clarificationRepository.load(clarification.id) expect(reloaded.status).toBe('answered') expect(reloaded.selectedCandidateId).toBe(candidateId) expect(reloaded.resolution).toEqual([{ operation: 'removeNode', nodeId }]) @@ -256,9 +256,9 @@ describe('HITLService', () => { it('appends a custom candidate, marks it answered, and stamps the resolution', async () => { // The reviewer's answer uses the same lifecycle as a skill candidate. - // The new one shows up in the ticket's candidates with zero ops, and selectedCandidateId points at it. + // The new one shows up in the clarification's candidates with zero ops, and selectedCandidateId points at it. const fixture = await setupFixture() - const ticket = makeClarification(fixture.workspaceId, { + const clarification = makeClarification(fixture.workspaceId, { candidates: [{ id: mintTestId('cc') as ClarificationCandidateId, description: 'pre-existing', @@ -266,16 +266,16 @@ describe('HITLService', () => { proposedOperations: [], }], }) - await fixture.clarificationRepository.save(ticket) + await fixture.clarificationRepository.save(clarification) const answered = await fixture.service.answerClarification({ - clarificationId: ticket.id, + clarificationId: clarification.id, selection: { kind: 'custom', description: 'actually it should be hybrid' }, userId, }) expect(answered.status).toBe('answered') - const reloaded = await fixture.clarificationRepository.load(ticket.id) + const reloaded = await fixture.clarificationRepository.load(clarification.id) expect(reloaded.status).toBe('answered') expect(reloaded.candidates).toHaveLength(2) const appended = reloaded.candidates[1] @@ -291,7 +291,7 @@ describe('HITLService', () => { // when it tries to wrap the failure into a Proposal. const fixture = await setupFixture() const candidateId = mintTestId('cc') as ClarificationCandidateId - const ticket = makeClarification(fixture.workspaceId, { + const clarification = makeClarification(fixture.workspaceId, { candidates: [{ id: candidateId, description: 'yes', @@ -299,26 +299,26 @@ describe('HITLService', () => { proposedOperations: [{ operation: 'removeNode', nodeId: 'ghost' as NodeId }], }], }) - await fixture.clarificationRepository.save(ticket) + await fixture.clarificationRepository.save(clarification) await expect( fixture.service.answerClarification({ - clarificationId: ticket.id, + clarificationId: clarification.id, selection: { kind: 'existing', candidateId }, userId, }), ).rejects.toThrow() - const reloaded = await fixture.clarificationRepository.load(ticket.id) + const reloaded = await fixture.clarificationRepository.load(clarification.id) expect(reloaded.status).toBe('pending') }) }) describe('markClarificationApplied', () => { - it('moves an answered ticket to applied and stamps proposalId', async () => { + it('moves an answered clarification to applied and stamps proposalId', async () => { const fixture = await setupFixture() const candidateId = mintTestId('cc') as ClarificationCandidateId - const ticket = makeClarification(fixture.workspaceId, { + const clarification = makeClarification(fixture.workspaceId, { status: 'answered', selectedCandidateId: candidateId, candidates: [{ @@ -328,23 +328,23 @@ describe('HITLService', () => { proposedOperations: [], }], }) - await fixture.clarificationRepository.save(ticket) + await fixture.clarificationRepository.save(clarification) const proposalId = mintTestId('p') as ProposalId - const applied = await fixture.service.markClarificationApplied(ticket.id, userId, proposalId) + const applied = await fixture.service.markClarificationApplied(clarification.id, userId, proposalId) expect(applied.status).toBe('applied') expect(applied.proposalId).toBe(proposalId) - const reloaded = await fixture.clarificationRepository.load(ticket.id) + const reloaded = await fixture.clarificationRepository.load(clarification.id) expect(reloaded.status).toBe('applied') expect(reloaded.proposalId).toBe(proposalId) }) - it('moves an answered ticket to applied with no proposalId when resolution had no graph impact', async () => { + it('moves an answered clarification to applied with no proposalId when resolution had no graph impact', async () => { const fixture = await setupFixture() const candidateId = mintTestId('cc') as ClarificationCandidateId - const ticket = makeClarification(fixture.workspaceId, { + const clarification = makeClarification(fixture.workspaceId, { status: 'answered', selectedCandidateId: candidateId, candidates: [{ @@ -354,22 +354,22 @@ describe('HITLService', () => { proposedOperations: [], }], }) - await fixture.clarificationRepository.save(ticket) + await fixture.clarificationRepository.save(clarification) - const applied = await fixture.service.markClarificationApplied(ticket.id, userId) + const applied = await fixture.service.markClarificationApplied(clarification.id, userId) expect(applied.status).toBe('applied') expect(applied.proposalId).toBeUndefined() - const reloaded = await fixture.clarificationRepository.load(ticket.id) + const reloaded = await fixture.clarificationRepository.load(clarification.id) expect(reloaded.status).toBe('applied') expect(reloaded.proposalId).toBeUndefined() }) - it('refuses to apply a ticket that has not been answered yet', async () => { + it('refuses to apply a clarification that has not been answered yet', async () => { const fixture = await setupFixture() const candidateId = mintTestId('cc') as ClarificationCandidateId - const ticket = makeClarification(fixture.workspaceId, { + const clarification = makeClarification(fixture.workspaceId, { candidates: [{ id: candidateId, description: 'a', @@ -377,11 +377,11 @@ describe('HITLService', () => { proposedOperations: [], }], }) - await fixture.clarificationRepository.save(ticket) + await fixture.clarificationRepository.save(clarification) const proposalId = mintTestId('p') as ProposalId await expect( - fixture.service.markClarificationApplied(ticket.id, userId, proposalId), + fixture.service.markClarificationApplied(clarification.id, userId, proposalId), ).rejects.toThrow(ConflictError) }) }) diff --git a/packages/core/test/application/HITLServiceHistoryHooks.test.ts b/packages/core/test/application/HITLServiceHistoryHooks.test.ts index 8b705ab..3f2442b 100644 --- a/packages/core/test/application/HITLServiceHistoryHooks.test.ts +++ b/packages/core/test/application/HITLServiceHistoryHooks.test.ts @@ -65,7 +65,7 @@ async function setupWithHistory(options: { withHistory?: boolean } = {}) { return { service, history, serializer, workspaceId: workspace.id, workspace, proposalRepository, clarificationRepository, clock } } -function makeAnsweredTicket(workspaceId: WorkspaceId): Clarification { +function makeAnsweredClarification(workspaceId: WorkspaceId): Clarification { return new Clarification({ id: mintTestId('ct') as ClarificationId, workspaceId, @@ -80,7 +80,7 @@ function makeAnsweredTicket(workspaceId: WorkspaceId): Clarification { }) } -function makePendingTicket(workspaceId: WorkspaceId): Clarification { +function makePendingClarification(workspaceId: WorkspaceId): Clarification { return new Clarification({ id: mintTestId('ct') as ClarificationId, workspaceId, @@ -127,7 +127,7 @@ describe('HITLService — workspace history hooks', () => { it('submitClarification commits with kind=clarification-submit', async () => { const { service, history, workspaceId } = await setupWithHistory() - const ticket = await service.submitClarification({ + const clarification = await service.submitClarification({ workspaceId, question: 'agg or entity?', candidates: [], @@ -135,7 +135,7 @@ describe('HITLService — workspace history hooks', () => { expect(history.commit).toHaveBeenCalledTimes(1) const message = history.commit.mock.calls[0]![1] expect(message.kind).toBe('clarification-submit') - expect(message.clarificationId).toBe(ticket.id) + expect(message.clarificationId).toBe(clarification.id) }) it('applyProposal serialises the post-mutation graph then commits with kind proposal-apply', async () => { @@ -171,11 +171,11 @@ describe('HITLService — workspace history hooks', () => { it('answerClarification commits with kind=clarification-answer', async () => { const { service, history, workspaceId, clarificationRepository } = await setupWithHistory() - const ticket = makePendingTicket(workspaceId) - await clarificationRepository.save(ticket) + const clarification = makePendingClarification(workspaceId) + await clarificationRepository.save(clarification) await service.answerClarification({ - clarificationId: ticket.id, + clarificationId: clarification.id, selection: { kind: 'existing', candidateId }, userId, }) @@ -183,34 +183,34 @@ describe('HITLService — workspace history hooks', () => { expect(history.commit).toHaveBeenCalledTimes(1) const message = history.commit.mock.calls[0]![1] expect(message.kind).toBe('clarification-answer') - expect(message.clarificationId).toBe(ticket.id) + expect(message.clarificationId).toBe(clarification.id) }) it('markClarificationApplied commits with kind=clarification-apply and stamps proposalId when present', async () => { const { service, history, workspaceId, clarificationRepository } = await setupWithHistory() - const ticket = makeAnsweredTicket(workspaceId) - await clarificationRepository.save(ticket) + const clarification = makeAnsweredClarification(workspaceId) + await clarificationRepository.save(clarification) const proposalId = mintTestId('p') as ProposalId - await service.markClarificationApplied(ticket.id, userId, proposalId) + await service.markClarificationApplied(clarification.id, userId, proposalId) expect(history.commit).toHaveBeenCalledTimes(1) const message = history.commit.mock.calls[0]![1] expect(message.kind).toBe('clarification-apply') - expect(message.clarificationId).toBe(ticket.id) + expect(message.clarificationId).toBe(clarification.id) expect(message.proposalId).toBe(proposalId) }) it('skipClarification commits with kind=clarification-skip', async () => { const { service, history, workspaceId, clarificationRepository } = await setupWithHistory() - const ticket = makePendingTicket(workspaceId) - await clarificationRepository.save(ticket) + const clarification = makePendingClarification(workspaceId) + await clarificationRepository.save(clarification) - await service.skipClarification(ticket.id, 'not relevant', userId) + await service.skipClarification(clarification.id, 'not relevant', userId) expect(history.commit).toHaveBeenCalledTimes(1) expect(history.commit.mock.calls[0]![1].kind).toBe('clarification-skip') - expect(history.commit.mock.calls[0]![1].clarificationId).toBe(ticket.id) + expect(history.commit.mock.calls[0]![1].clarificationId).toBe(clarification.id) }) it('skips git hooks entirely when deps are absent (in-process / test mode)', async () => { diff --git a/packages/core/test/domain/hitl/Clarification.test.ts b/packages/core/test/domain/hitl/Clarification.test.ts index ef9d430..dfaff6d 100644 --- a/packages/core/test/domain/hitl/Clarification.test.ts +++ b/packages/core/test/domain/hitl/Clarification.test.ts @@ -42,14 +42,14 @@ function data(overrides: Partial = {}): ClarificationData { describe('Clarification', () => { describe('resolveCandidate', () => { it('returns the candidate operations', () => { - const ticket = new Clarification(data()) - const operations = ticket.resolveCandidate('cc-1' as ClarificationCandidateId) + const clarification = new Clarification(data()) + const operations = clarification.resolveCandidate('cc-1' as ClarificationCandidateId) expect(operations).toHaveLength(1) }) it('throws NotFoundError when candidate id missing', () => { - const ticket = new Clarification(data()) - expect(() => ticket.resolveCandidate('missing' as ClarificationCandidateId)).toThrow(NotFoundError) + const clarification = new Clarification(data()) + expect(() => clarification.resolveCandidate('missing' as ClarificationCandidateId)).toThrow(NotFoundError) }) }) @@ -62,14 +62,14 @@ describe('Clarification', () => { expect(answered.resolution).toEqual([{ operation: 'removeNode', nodeId: 'n-x' }]) }) - it('throws ConflictError when ticket is not pending', () => { - const ticket = new Clarification(data({ status: 'answered' })) - expect(() => ticket.markAnswered('cc-1' as ClarificationCandidateId, userId)).toThrow(ConflictError) + it('throws ConflictError when clarification is not pending', () => { + const clarification = new Clarification(data({ status: 'answered' })) + expect(() => clarification.markAnswered('cc-1' as ClarificationCandidateId, userId)).toThrow(ConflictError) }) it('throws NotFoundError when candidate id missing', () => { - const ticket = new Clarification(data()) - expect(() => ticket.markAnswered('missing' as ClarificationCandidateId, userId)).toThrow(NotFoundError) + const clarification = new Clarification(data()) + expect(() => clarification.markAnswered('missing' as ClarificationCandidateId, userId)).toThrow(NotFoundError) }) }) @@ -90,40 +90,40 @@ describe('Clarification', () => { expect(applied.proposalId).toBeUndefined() }) - it('throws ConflictError when ticket is not answered (must answer first)', () => { - const ticket = new Clarification(data()) - expect(() => ticket.markApplied('p-1' as ProposalId)).toThrow(ConflictError) + it('throws ConflictError when clarification is not answered (must answer first)', () => { + const clarification = new Clarification(data()) + expect(() => clarification.markApplied('p-1' as ProposalId)).toThrow(ConflictError) }) }) describe('markSkipped', () => { - it('returns a new ticket in skipped status', () => { + it('returns a new clarification in skipped status', () => { const skipped = new Clarification(data()).markSkipped(userId) expect(skipped.status).toBe('skipped') }) - it('throws ConflictError when ticket is not pending', () => { - const ticket = new Clarification(data({ status: 'skipped' })) - expect(() => ticket.markSkipped(userId)).toThrow(ConflictError) + it('throws ConflictError when clarification is not pending', () => { + const clarification = new Clarification(data({ status: 'skipped' })) + expect(() => clarification.markSkipped(userId)).toThrow(ConflictError) }) }) describe('appendCandidate', () => { - it('appends a new candidate while the ticket is pending', () => { - const ticket = new Clarification(data()) - const extended = ticket.appendCandidate(candidate('cc-custom')) + it('appends a new candidate while the clarification is pending', () => { + const clarification = new Clarification(data()) + const extended = clarification.appendCandidate(candidate('cc-custom')) expect(extended.candidates.map(c => c.id)).toEqual(['cc-1', 'cc-2', 'cc-custom']) expect(extended.status).toBe('pending') }) it('throws ConflictError on duplicate candidate id', () => { - const ticket = new Clarification(data()) - expect(() => ticket.appendCandidate(candidate('cc-1'))).toThrow(ConflictError) + const clarification = new Clarification(data()) + expect(() => clarification.appendCandidate(candidate('cc-1'))).toThrow(ConflictError) }) - it('throws ConflictError when ticket is not pending', () => { - const ticket = new Clarification(data({ status: 'answered' })) - expect(() => ticket.appendCandidate(candidate('cc-custom'))).toThrow(ConflictError) + it('throws ConflictError when clarification is not pending', () => { + const clarification = new Clarification(data({ status: 'answered' })) + expect(() => clarification.appendCandidate(candidate('cc-custom'))).toThrow(ConflictError) }) }) }) diff --git a/packages/ontology-ddd/src/DDDOntologyPlugin.ts b/packages/ontology-ddd/src/DDDOntologyPlugin.ts index 204cacf..c276f43 100644 --- a/packages/ontology-ddd/src/DDDOntologyPlugin.ts +++ b/packages/ontology-ddd/src/DDDOntologyPlugin.ts @@ -1,5 +1,30 @@ +import type { EdgeTypeDescriptor, NodeTypeDescriptor } from '@braidhq/core' +import type { LocalizedText } from '@braidhq/schema' +import type { SourceRoleInput } from '@braidhq/sdk' import { EdgeTypeId, NodeTypeId, SkillId } from '@braidhq/schema' import { defineOntologyPlugin } from '@braidhq/sdk' +import enLabels from './locales/en/labels.js' +import zhHantLabels from './locales/zh-Hant/labels.js' + +type LabelKind = keyof typeof enLabels + +/** Build a descriptor label from the per-locale label files, keyed by id. */ +function localeLabel(kind: LabelKind, id: string): LocalizedText { + const en = (enLabels[kind] as Record)[id] ?? id + const zhHant = (zhHantLabels[kind] as Record)[id] + return zhHant ? { 'en': en, 'zh-Hant': zhHant } : en +} + +// Attach labels from the locales folder to label-less descriptors, keyed by id. +function localeRoles(roles: readonly Omit[]): SourceRoleInput[] { + return roles.map(role => ({ ...role, label: localeLabel('sourceRoles', role.id) })) +} +function localeNodes(nodes: readonly Omit[]): NodeTypeDescriptor[] { + return nodes.map(node => ({ ...node, label: localeLabel('nodeTypes', node.id) })) +} +function localeEdges(edges: readonly Omit[]): EdgeTypeDescriptor[] { + return edges.map(edge => ({ ...edge, label: localeLabel('edgeTypes', edge.id) })) +} /** * The default DDD ontology, consolidating Strategic DDD (Evans), @@ -21,10 +46,10 @@ export const dddOntology = defineOntologyPlugin({ // this value prop fails. Intent docs are the unit-bearing role, each doc // is one extraction unit and its sync drives the Reactor. Code is context // the per-unit skill reads, and seeds derived mode when no intent exists. - sourceRoles: [ - { id: 'intent', label: 'Intent', required: true, unitBearing: true, pathSegment: 'intents' }, - { id: 'code', label: 'Code', required: true, pathSegment: 'codebases' }, - ], + sourceRoles: localeRoles([ + { id: 'intent', required: true, unitBearing: true, pathSegment: 'intents' }, + { id: 'code', required: true, pathSegment: 'codebases' }, + ]), // SKILL.md prompts shipped with this ontology. // They encode DDD-specific reasoning, like the Context Mapping edges, @@ -48,10 +73,9 @@ export const dddOntology = defineOntologyPlugin({ }, ], - nodeTypes: [ + nodeTypes: localeNodes([ { id: NodeTypeId.parse('boundedContext'), - label: 'Bounded Context', description: 'A subsystem with its own ubiquitous language; everything inside is one consistency boundary. Strategic DDD primitive (Evans Blue Book Part IV; Khononov 2021 ch. 3).', color: 'oklch(0.7 0.035 260)', defaultVisible: true, @@ -59,7 +83,6 @@ export const dddOntology = defineOntologyPlugin({ }, { id: NodeTypeId.parse('aggregate'), - label: 'Aggregate', description: 'Cluster of domain objects treated as a unit for data changes; has a single root entity that controls access. Tactical DDD primitive (Evans Blue Book Part II; Vernon IDDD ch. 10; Khononov 2021 ch. 6).', color: 'oklch(0.7 0.12 155)', defaultVisible: true, @@ -67,52 +90,45 @@ export const dddOntology = defineOntologyPlugin({ }, { id: NodeTypeId.parse('command'), - label: 'Command', description: 'Imperative request that asks the system to change state; names use verbs (placeOrder, cancelOrder). CQRS primitive (Young 2010; Khononov 2021 ch. 11). The blue sticky in EventStorming.', color: 'oklch(0.65 0.14 250)', renderHint: { expandedUnder: NodeTypeId.parse('aggregate') }, }, { id: NodeTypeId.parse('query'), - label: 'Query', description: 'Read-only request that returns state without modifying it. CQRS primitive (Young 2010; Khononov 2021 ch. 11). Strict CQRS routes queries to a dedicated read-model node; in the absence of that node type, queries attach to the aggregate.', color: 'oklch(0.7 0.11 220)', renderHint: { expandedUnder: NodeTypeId.parse('aggregate') }, }, { id: NodeTypeId.parse('event'), - label: 'Domain Event', description: 'Past-tense fact about something that has already happened (OrderPlaced, ItemAdded). Tactical DDD primitive (Vernon IDDD ch. 8) and the orange sticky in EventStorming.', color: 'oklch(0.76 0.13 80)', renderHint: { expandedUnder: NodeTypeId.parse('command') }, }, { id: NodeTypeId.parse('rule'), - label: 'Business Rule', description: 'Invariant that must hold (MaxItemsRule, PositiveQuantityRule). Tactical DDD (Evans Specification pattern; Vernon IDDD invariants). Per-operation rules attach to a command or query; aggregate-wide invariants attach to the aggregate itself.', color: 'oklch(0.65 0.15 20)', renderHint: { expandedUnder: NodeTypeId.parse('command') }, }, { id: NodeTypeId.parse('actor'), - label: 'Actor', description: 'External role that triggers a command or query (Customer, Admin, BillingService). EventStorming primitive (Brandolini; the yellow stick-figure sticky) and Khononov 2021. Not in strict Evans / Vernon canon, where the issuer lives on the command\'s metadata.', color: 'oklch(0.72 0.11 310)', renderHint: { section: 'Actors' }, }, { id: NodeTypeId.parse('policy'), - label: 'Policy', description: 'Automatic reaction: "when event X happens, do Y". EventStorming primitive (Brandolini; the purple sticky) and Khononov 2021. Materialises Vernon\'s Process Manager / Saga pattern when the reaction crosses aggregates or has its own naming.', color: 'oklch(0.62 0.15 310)', renderHint: { section: 'Reactions' }, }, - ], + ]), - edgeTypes: [ + edgeTypes: localeEdges([ { id: EdgeTypeId.parse('contains'), - label: 'contains', description: 'A BoundedContext contains aggregates. Commands, queries, events, and rules belong to an aggregate and are reached via accepts, emits, or constrainedBy. Strategic DDD (Evans Blue Book Part IV).', fromTypes: [NodeTypeId.parse('boundedContext')], toTypes: [NodeTypeId.parse('aggregate')], @@ -121,7 +137,6 @@ export const dddOntology = defineOntologyPlugin({ }, { id: EdgeTypeId.parse('accepts'), - label: 'accepts', description: 'Aggregate is the entry point for operations against its state; commands modify the aggregate and queries read it. Tactical DDD with CQRS (Khononov 2021 ch. 11). Strict CQRS would route queries to a dedicated read-model node; this ontology routes both through the aggregate.', fromTypes: [NodeTypeId.parse('aggregate')], toTypes: [NodeTypeId.parse('command'), NodeTypeId.parse('query')], @@ -130,7 +145,6 @@ export const dddOntology = defineOntologyPlugin({ }, { id: EdgeTypeId.parse('emits'), - label: 'emits', description: 'A command produces an event as the result of executing on its aggregate. Either source is valid in this ontology: command-source (CQRS / EventStorming visual reading: cmd to evt) and aggregate-source (Vernon IDDD structural reading: agg to evt). Khononov 2021 illustrates both; prefer the command-source form when extracting from PRD/spec language and the aggregate-source form when describing state ownership.', fromTypes: [NodeTypeId.parse('command'), NodeTypeId.parse('aggregate')], toTypes: [NodeTypeId.parse('event')], @@ -139,7 +153,6 @@ export const dddOntology = defineOntologyPlugin({ }, { id: EdgeTypeId.parse('triggers'), - label: 'triggers', description: 'Process-manager / saga flow: an event drives a downstream command or policy, often in a different aggregate. EventStorming flow notation (Brandolini); CQRS saga pattern (Khononov 2021 ch. 11).', fromTypes: [NodeTypeId.parse('event')], toTypes: [NodeTypeId.parse('command'), NodeTypeId.parse('policy')], @@ -148,7 +161,6 @@ export const dddOntology = defineOntologyPlugin({ }, { id: EdgeTypeId.parse('enacts'), - label: 'enacts', description: 'A policy issues a command as its reaction. The "do Y" half of EventStorming\'s "when X then Y" / Vernon\'s Process Manager (Brandolini; Vernon IDDD ch. 13; Khononov 2021).', fromTypes: [NodeTypeId.parse('policy')], toTypes: [NodeTypeId.parse('command')], @@ -157,7 +169,6 @@ export const dddOntology = defineOntologyPlugin({ }, { id: EdgeTypeId.parse('constrainedBy'), - label: 'constrained by', description: 'Per-operation rule: a command or query is constrained by a specific rule. Aggregate-wide invariant: an aggregate is constrained by a rule that must hold across all of its operations. Tactical DDD (Evans Specification pattern; Vernon IDDD invariants).', fromTypes: [NodeTypeId.parse('command'), NodeTypeId.parse('query'), NodeTypeId.parse('aggregate')], toTypes: [NodeTypeId.parse('rule')], @@ -166,7 +177,6 @@ export const dddOntology = defineOntologyPlugin({ }, { id: EdgeTypeId.parse('dependsOn'), - label: 'depends on', description: 'Aggregates reference other aggregates by id only. Tactical DDD (Vernon IDDD "Reference Other Aggregates by Identity" rule; Khononov 2021 ch. 6). Cross-aggregate command or query coupling is expressed through triggers rather than direct references.', fromTypes: [NodeTypeId.parse('aggregate')], toTypes: [NodeTypeId.parse('aggregate')], @@ -175,7 +185,6 @@ export const dddOntology = defineOntologyPlugin({ }, { id: EdgeTypeId.parse('performedBy'), - label: 'performed by', description: 'A command or query is triggered by an actor. EventStorming convention (Brandolini) and Khononov 2021. Not in strict Evans / Vernon canon, where the issuer lives on the command\'s metadata rather than as a graph edge.', fromTypes: [NodeTypeId.parse('command'), NodeTypeId.parse('query')], toTypes: [NodeTypeId.parse('actor')], @@ -190,7 +199,6 @@ export const dddOntology = defineOntologyPlugin({ // not derivable from individual feature slices. { id: EdgeTypeId.parse('partnership'), - label: 'partnership', description: 'Symmetric: two BoundedContexts are committed to succeed or fail together; coordinated planning and joint releases. Strategic DDD Context Mapping (Evans Blue Book Part IV; Khononov 2021 ch. 4).', fromTypes: [NodeTypeId.parse('boundedContext')], toTypes: [NodeTypeId.parse('boundedContext')], @@ -199,7 +207,6 @@ export const dddOntology = defineOntologyPlugin({ }, { id: EdgeTypeId.parse('customerSupplier'), - label: 'customer-supplier', description: 'Asymmetric (customer downstream, supplier upstream): the customer BoundedContext depends on the supplier and has political pull to ask for changes. Strategic DDD Context Mapping (Evans Blue Book Part IV; Khononov 2021 ch. 4).', fromTypes: [NodeTypeId.parse('boundedContext')], toTypes: [NodeTypeId.parse('boundedContext')], @@ -208,7 +215,6 @@ export const dddOntology = defineOntologyPlugin({ }, { id: EdgeTypeId.parse('conformist'), - label: 'conformist', description: 'Asymmetric (conformist downstream, upstream uncooperative): the downstream BoundedContext depends on an upstream it has no political pull over and adopts the upstream\'s model as-is. Strategic DDD Context Mapping (Evans Blue Book Part IV; Khononov 2021 ch. 4).', fromTypes: [NodeTypeId.parse('boundedContext')], toTypes: [NodeTypeId.parse('boundedContext')], @@ -217,7 +223,6 @@ export const dddOntology = defineOntologyPlugin({ }, { id: EdgeTypeId.parse('sharedKernel'), - label: 'shared kernel', description: 'Symmetric: two BoundedContexts intentionally share a small piece of model (often a value object). Any change to the shared part requires coordination between both teams. Strategic DDD Context Mapping (Evans Blue Book Part IV; Khononov 2021 ch. 4).', fromTypes: [NodeTypeId.parse('boundedContext')], toTypes: [NodeTypeId.parse('boundedContext')], @@ -226,7 +231,6 @@ export const dddOntology = defineOntologyPlugin({ }, { id: EdgeTypeId.parse('anticorruptionLayer'), - label: 'anticorruption layer', description: 'Asymmetric (acl-owner downstream, upstream isolated from): the downstream BoundedContext isolates itself from the upstream by building a translation layer so its internal model is not corrupted by the upstream\'s shape. Strategic DDD Context Mapping (Evans Blue Book Part IV; Khononov 2021 ch. 4).', fromTypes: [NodeTypeId.parse('boundedContext')], toTypes: [NodeTypeId.parse('boundedContext')], @@ -235,7 +239,6 @@ export const dddOntology = defineOntologyPlugin({ }, { id: EdgeTypeId.parse('openHostService'), - label: 'open host service', description: 'Asymmetric (host upstream, consumer downstream): the upstream BoundedContext offers a well-defined open protocol any downstream can consume without bespoke negotiation. Strategic DDD Context Mapping (Evans Blue Book Part IV; Khononov 2021 ch. 4).', fromTypes: [NodeTypeId.parse('boundedContext')], toTypes: [NodeTypeId.parse('boundedContext')], @@ -244,14 +247,13 @@ export const dddOntology = defineOntologyPlugin({ }, { id: EdgeTypeId.parse('publishedLanguage'), - label: 'published language', description: 'Asymmetric (publisher upstream, consumer downstream): the upstream BoundedContext publishes a documented schema or format; downstreams consume it as-is. Often combined with openHostService. Strategic DDD Context Mapping (Evans Blue Book Part IV; Khononov 2021 ch. 4).', fromTypes: [NodeTypeId.parse('boundedContext')], toTypes: [NodeTypeId.parse('boundedContext')], cardinality: '1:N', color: 'oklch(0.7 0.15 100)', }, - ], + ]), // Batch and reactor binding. // The per-unit skill is ddd:extract. diff --git a/packages/ontology-ddd/src/locales/en/labels.ts b/packages/ontology-ddd/src/locales/en/labels.ts new file mode 100644 index 0000000..1e9c81e --- /dev/null +++ b/packages/ontology-ddd/src/locales/en/labels.ts @@ -0,0 +1,40 @@ +/** + * English labels for the DDD ontology vocabulary, keyed by id. + * The base locale. zh-Hant lives alongside in ../zh-Hant/labels. + * Descriptions carry academic citations and stay in DDDOntologyPlugin. + */ +export const labels = { + sourceRoles: { + intent: 'Intent', + code: 'Code', + }, + nodeTypes: { + boundedContext: 'Bounded Context', + aggregate: 'Aggregate', + command: 'Command', + query: 'Query', + event: 'Domain Event', + rule: 'Business Rule', + actor: 'Actor', + policy: 'Policy', + }, + edgeTypes: { + contains: 'contains', + accepts: 'accepts', + emits: 'emits', + triggers: 'triggers', + enacts: 'enacts', + constrainedBy: 'constrained by', + dependsOn: 'depends on', + performedBy: 'performed by', + partnership: 'partnership', + customerSupplier: 'customer-supplier', + conformist: 'conformist', + sharedKernel: 'shared kernel', + anticorruptionLayer: 'anticorruption layer', + openHostService: 'open host service', + publishedLanguage: 'published language', + }, +} as const + +export default labels diff --git a/packages/ontology-ddd/src/locales/zh-Hant/labels.ts b/packages/ontology-ddd/src/locales/zh-Hant/labels.ts new file mode 100644 index 0000000..c4ee726 --- /dev/null +++ b/packages/ontology-ddd/src/locales/zh-Hant/labels.ts @@ -0,0 +1,18 @@ +/** + * Traditional Chinese labels for the DDD ontology vocabulary, keyed by id. + * Node and edge types stay English, the DDD ubiquitous language. + * Only source roles, shown in the source wizard, translate. + * A missing id falls back to the English label. + */ +export const labels = { + sourceRoles: { + intent: '意圖', + code: '程式碼', + }, + // Node and edge types stay fully English, the DDD ubiquitous language. + // One graph must not mix Chinese and English type badges. + nodeTypes: {}, + edgeTypes: {}, +} as const + +export default labels diff --git a/packages/schema/src/clarification.ts b/packages/schema/src/clarification.ts index e81d7e0..fae22bc 100644 --- a/packages/schema/src/clarification.ts +++ b/packages/schema/src/clarification.ts @@ -23,7 +23,7 @@ export type ClarificationStatus = z.infer export const ClarificationOrigin = z.enum(['skill', 'human']) export type ClarificationOrigin = z.infer -/** Human picks this when filing to steer the AI. Skill tickets leave it unset. */ +/** Human picks this when filing to steer the AI. Skill clarifications leave it unset. */ export const ClarificationAmbiguityType = z.enum(['gap', 'contradiction', 'ambiguous', 'assumption']) export type ClarificationAmbiguityType = z.infer @@ -49,13 +49,13 @@ export const Clarification = z.object({ // Display-name snapshot at submit time. Absent for the 'system' owner. ownerDisplayName: z.string().min(1).optional(), // Owner's kind snapshotted at submit time. - // Absent means a human's private ticket, 'service' is autonomous and owner-visible. + // Absent means a human's private clarification, 'service' is autonomous and owner-visible. ownerKind: UserKind.optional(), // Set when the resolution becomes a Proposal, so the UI can link the two. proposalId: ProposalId.optional(), externalReferences: z.array(ExternalReference).optional(), origin: ClarificationOrigin, - // Free-form background on a human-filed issue. Skill tickets leave it empty. + // Free-form background on a human-filed issue. Skill clarifications leave it empty. context: z.string().max(2000).optional(), // Node the human believes the issue concerns, to help the AI scope its resolution. relatedNode: NodeId.optional(), @@ -76,7 +76,7 @@ export const ClarificationCreate = z.object({ export type ClarificationCreate = z.infer /** - * The POST body for creating a ticket. Workspace comes from the path, + * The POST body for creating a clarification. Workspace comes from the path, * and human-authored candidates omit their id for the server to mint. */ export const ClarificationCreateBody = ClarificationCreate @@ -89,7 +89,7 @@ export const ClarificationFilter = z.object({ statuses: z.array(ClarificationStatus).optional(), limit: z.number().int().positive().optional(), offset: z.number().int().nonnegative().optional(), - // When set, hides others' pending tickets. Non-pending stay visible, absent shows all. + // When set, hides others' pending clarifications. Non-pending stay visible, absent shows all. viewerId: UserId.optional(), // Owner-only, also shows service-owned (autonomous) pending to this viewer. includeServiceOwned: z.boolean().optional(), diff --git a/packages/schema/src/event.ts b/packages/schema/src/event.ts index 65eacca..85d02e0 100644 --- a/packages/schema/src/event.ts +++ b/packages/schema/src/event.ts @@ -50,26 +50,26 @@ export type ProposalRejectedEvent = z.infer export const ClarificationCreatedEvent = WorkspaceEventBase.extend({ type: z.literal('clarification.created'), - ticketId: ClarificationId, + clarificationId: ClarificationId, }) export type ClarificationCreatedEvent = z.infer export const ClarificationAnsweredEvent = WorkspaceEventBase.extend({ type: z.literal('clarification.answered'), - ticketId: ClarificationId, + clarificationId: ClarificationId, }) export type ClarificationAnsweredEvent = z.infer export const ClarificationAppliedEvent = WorkspaceEventBase.extend({ type: z.literal('clarification.applied'), - ticketId: ClarificationId, + clarificationId: ClarificationId, proposalId: ProposalId.optional(), }) export type ClarificationAppliedEvent = z.infer export const ClarificationSkippedEvent = WorkspaceEventBase.extend({ type: z.literal('clarification.skipped'), - ticketId: ClarificationId, + clarificationId: ClarificationId, }) export type ClarificationSkippedEvent = z.infer diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index ed88304..2b3c352 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -10,6 +10,7 @@ export * from './error.js' export * from './event.js' export * from './graph-validation.js' export * from './history.js' +export * from './locale.js' export * from './mcp.js' export * from './model.js' export * from './ontology.js' diff --git a/packages/schema/src/locale.ts b/packages/schema/src/locale.ts new file mode 100644 index 0000000..81485c5 --- /dev/null +++ b/packages/schema/src/locale.ts @@ -0,0 +1,44 @@ +import { z } from 'zod' + +/** + * Locales the product ships translations for. Closed set, so an enum. + * The Studio UI locale and plugin-declared localized text draw from it. + */ +export const Locale = z.enum(['en', 'zh-Hant']) +export type Locale = z.infer + +export const FALLBACK_LOCALE: Locale = 'en' + +/** A supported locale plus its own endonym, shown in a language picker. */ +export interface LocaleOption { + code: Locale + label: string +} + +export const SUPPORTED_LOCALES = [ + { code: 'en', label: 'English' }, + { code: 'zh-Hant', label: '繁體中文' }, +] as const satisfies readonly LocaleOption[] + +export function isLocale(value: unknown): value is Locale { + return Locale.safeParse(value).success +} + +/** + * Display text with optional per-locale variants. + * A bare string applies to all locales. + * A partial map translates only the terms a plugin wants. + */ +export type LocalizedText = string | Partial> + +/** Build a LocalizedText zod schema, reusing the same constraints for each variant. */ +export function localizedText(value: z.ZodString = z.string()): z.ZodType { + return z.union([value, z.partialRecord(Locale, value)]) +} + +/** Resolve localized text for a locale, falling back when a variant is missing. */ +export function localize(text: LocalizedText, locale: Locale, fallback: Locale = FALLBACK_LOCALE): string { + if (typeof text === 'string') + return text + return text[locale] ?? text[fallback] ?? Object.values(text)[0] ?? '' +} diff --git a/packages/schema/src/ontology.ts b/packages/schema/src/ontology.ts index 5a96d7b..179a217 100644 --- a/packages/schema/src/ontology.ts +++ b/packages/schema/src/ontology.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import { OntologyId } from './common.js' +import { localizedText } from './locale.js' import { SourceRole } from './source.js' export const NodeTypeId = z.string().min(1).brand<'NodeTypeId'>() @@ -21,7 +22,7 @@ export type NodeTypeRenderHint = z.infer export const NodeTypeDescriptor = z.object({ id: NodeTypeId, - label: z.string().min(1).max(40), + label: localizedText(z.string().min(1).max(40)), description: z.string().optional(), allowedStatuses: z.array(NodeStatus).optional(), color: z.string().optional(), @@ -35,7 +36,7 @@ export type EdgeCardinality = z.infer export const EdgeTypeDescriptor = z.object({ id: EdgeTypeId, - label: z.string().min(1).max(40), + label: localizedText(z.string().min(1).max(40)), description: z.string().optional(), fromTypes: z.array(NodeTypeId), toTypes: z.array(NodeTypeId), @@ -50,7 +51,7 @@ export type EdgeTypeDescriptor = z.infer */ export const SourceRoleDescriptor = z.object({ id: SourceRole, - label: z.string().min(1), + label: localizedText(z.string().min(1)), // Sources of this role must be present for the ontology to run. required: z.boolean().optional(), // Sources of this role enumerate into batch units, and their sync drives the Reactor. diff --git a/packages/schema/src/skill.ts b/packages/schema/src/skill.ts index e077bd1..3a4a7b7 100644 --- a/packages/schema/src/skill.ts +++ b/packages/schema/src/skill.ts @@ -79,7 +79,7 @@ export const SkillInputSourceProvider = z.object({ }) export type SkillInputSourceProvider = z.infer -/** Clarification tickets, filtered by status. Defaults to all statuses. */ +/** Clarifications, filtered by status. Defaults to all statuses. */ export const SkillInputClarificationProvider = z.object({ kind: z.literal('clarify'), filter: z.object({ diff --git a/packages/schema/test/clarification.test.ts b/packages/schema/test/clarification.test.ts index 7fe7a84..e25c259 100644 --- a/packages/schema/test/clarification.test.ts +++ b/packages/schema/test/clarification.test.ts @@ -36,8 +36,8 @@ describe('ClarificationCandidate', () => { }) describe('Clarification', () => { - it('parses pending ticket with candidates', () => { - const ticket = Clarification.parse({ + it('parses pending clarification with candidates', () => { + const clarification = Clarification.parse({ id: 'ct-1', workspaceId: 'w-1', question: 'voidTask vs cancelTask: same command?', @@ -49,7 +49,7 @@ describe('Clarification', () => { owner: 'system', origin: 'skill', }) - expect(ticket.candidates).toHaveLength(2) + expect(clarification.candidates).toHaveLength(2) }) it('rejects empty question', () => { @@ -64,8 +64,8 @@ describe('Clarification', () => { ).toBe(false) }) - it('accepts answered ticket with selection + resolution', () => { - const ticket = Clarification.parse({ + it('accepts answered clarification with selection + resolution', () => { + const clarification = Clarification.parse({ id: 'ct-1', workspaceId: 'w-1', question: 'x?', @@ -77,11 +77,11 @@ describe('Clarification', () => { selectedCandidateId: 'cc-1', resolution: [{ operation: 'removeNode', nodeId: 'n-1' }], }) - expect(ticket.selectedCandidateId).toBe('cc-1') + expect(clarification.selectedCandidateId).toBe('cc-1') }) it('accepts externalReferences (v2 forward-compat)', () => { - const ticket = Clarification.parse({ + const clarification = Clarification.parse({ id: 'ct-1', workspaceId: 'w-1', question: 'x?', @@ -91,7 +91,7 @@ describe('Clarification', () => { origin: 'skill', externalReferences: [{ kind: 'redmine', url: 'https://redmine.example.com/issues/1' }], }) - expect(ticket.externalReferences?.[0]?.kind).toBe('redmine') + expect(clarification.externalReferences?.[0]?.kind).toBe('redmine') }) }) @@ -131,7 +131,7 @@ describe('ClarificationCreate', () => { }) expect(created.origin).toBeUndefined() }) - it('accepts a human-filed ticket with context and ambiguityType', () => { + it('accepts a human-filed clarification with context and ambiguityType', () => { const created = ClarificationCreate.parse({ workspaceId: 'w-1', question: 'is the cap 50 or 99?', diff --git a/packages/sdk/src/defineOntologyPlugin.ts b/packages/sdk/src/defineOntologyPlugin.ts index a4cc615..f981ead 100644 --- a/packages/sdk/src/defineOntologyPlugin.ts +++ b/packages/sdk/src/defineOntologyPlugin.ts @@ -7,6 +7,7 @@ import type { } from '@braidhq/core' import type { EdgeTypeId, + LocalizedText, NodeTypeId, OntologyId, PluginId, @@ -26,7 +27,7 @@ import { /** Declarative source role an ontology contributes. `id` is branded on build. */ export interface SourceRoleInput { readonly id: string - readonly label: string + readonly label: LocalizedText readonly required?: boolean readonly unitBearing?: boolean readonly pathSegment?: string diff --git a/packages/server/src/infrastructure/hitl/FsClarificationRepository.ts b/packages/server/src/infrastructure/hitl/FsClarificationRepository.ts index 5f2a3f1..3980227 100644 --- a/packages/server/src/infrastructure/hitl/FsClarificationRepository.ts +++ b/packages/server/src/infrastructure/hitl/FsClarificationRepository.ts @@ -33,27 +33,27 @@ export class FsClarificationRepository implements ClarificationRepository { } async list(filter?: ClarificationFilter): Promise { - let tickets = await this.base.list({ + let clarifications = await this.base.list({ ...(filter?.workspaceId !== undefined ? { workspaceId: filter.workspaceId } : {}), ...(filter?.statuses !== undefined ? { statuses: filter.statuses } : {}), }) - // Pending tickets are personal, only the owner sees them. - // Answered, applied, and skipped tickets stay workspace-shared. + // Pending clarifications are personal, only the owner sees them. + // Answered, applied, and skipped clarifications stay workspace-shared. if (filter?.viewerId !== undefined) { const viewerId = filter.viewerId const includeServiceOwned = filter.includeServiceOwned ?? false - tickets = tickets.filter(ticket => - ticket.status !== 'pending' || ticket.owner === viewerId || (includeServiceOwned && ticket.ownerKind === 'service'), + clarifications = clarifications.filter(clarification => + clarification.status !== 'pending' || clarification.owner === viewerId || (includeServiceOwned && clarification.ownerKind === 'service'), ) } - return paginate(tickets, filter?.limit, filter?.offset) + return paginate(clarifications, filter?.limit, filter?.offset) } load(clarificationId: ClarificationId): Promise { return this.base.load(clarificationId) } - save(ticket: Clarification): Promise { - return this.base.save(ticket) + save(clarification: Clarification): Promise { + return this.base.save(clarification) } } diff --git a/packages/server/src/routes/clarifications.ts b/packages/server/src/routes/clarifications.ts index 06da848..b46fbb5 100644 --- a/packages/server/src/routes/clarifications.ts +++ b/packages/server/src/routes/clarifications.ts @@ -9,7 +9,7 @@ import { NotFoundResponse, ValidationFailureResponse, WorkspaceIdParam } from '. import { assertEntityInWorkspace } from './helpers.js' const ListQuery = z.object({ - status: z.union([ClarificationStatus, z.array(ClarificationStatus)]).optional().openapi({ description: 'Filter by ticket status. Pass one or many.' }), + status: z.union([ClarificationStatus, z.array(ClarificationStatus)]).optional().openapi({ description: 'Filter by clarification status. Pass one or many.' }), limit: z.coerce.number().int().positive().optional(), offset: z.coerce.number().int().nonnegative().optional(), showAll: z.coerce.boolean().optional().openapi({ description: 'Owner-only: bypass the personal-pending filter so every member\'s open questions are visible.' }), @@ -18,7 +18,7 @@ const ListQuery = z.object({ // Reviewer-facing answer body. // The selection is either an existing `candidateId`, // or a freshly authored `customCandidate` (description only). -// The server appends it to the ticket and answers in one transaction. +// The server appends it to the clarification and answers in one transaction. // `note` is a free-text rationale saved on the answer commit. // `userId` is accepted for backwards compat, // the authoritative value is the request context set by middleware. @@ -44,7 +44,7 @@ const SkipBody = z.object({ // The only legal transition the skill drives is `answered` to `applied`. // The proposalId is optional, present when a Proposal was produced, // absent when the chosen candidate had no graph impact. -// The skill then records the ticket as applied without a linking proposal. +// The skill then records the clarification as applied without a linking proposal. const ApplyBody = z.object({ status: z.literal('applied'), proposalId: ProposalId.optional(), @@ -117,7 +117,7 @@ const getClarificationRoute = createRoute({ request: { params: ClarificationIdParam }, responses: { 200: { - description: 'The requested ticket.', + description: 'The requested clarification.', content: { 'application/json': { schema: Clarification } }, }, 404: NotFoundResponse, @@ -136,7 +136,7 @@ const answerClarificationRoute = createRoute({ }, responses: { 200: { - description: 'The updated ticket.', + description: 'The updated clarification.', content: { 'application/json': { schema: Clarification } }, }, 404: NotFoundResponse, @@ -156,7 +156,7 @@ const applyClarificationRoute = createRoute({ }, responses: { 200: { - description: 'The updated ticket.', + description: 'The updated clarification.', content: { 'application/json': { schema: Clarification } }, }, 404: NotFoundResponse, @@ -175,7 +175,7 @@ const skipClarificationRoute = createRoute({ }, responses: { 200: { - description: 'The updated ticket.', + description: 'The updated clarification.', content: { 'application/json': { schema: Clarification } }, }, 404: NotFoundResponse, @@ -199,8 +199,8 @@ export function createClarificationRouter(deps: ClarificationRouterDeps): OpenAP ...c, id: c.id ?? newClarificationCandidateId(), })) - const ticket = await deps.hitlService.submitClarification({ ...body, workspaceId, candidates, submitterId }) - return context.json(ticket.toData(), 201) + const clarification = await deps.hitlService.submitClarification({ ...body, workspaceId, candidates, submitterId }) + return context.json(clarification.toData(), 201) }) router.openapi(listClarificationRoute, async (context) => { @@ -212,22 +212,22 @@ export function createClarificationRouter(deps: ClarificationRouterDeps): OpenAP // No viewer is an open composition (in-memory), which applies no personal filter. const viewerId = (!viewer || (showAll && isOwner)) ? undefined : getUserId(context) // Owners also see service-owned (autonomous) pending, since only they can act on it. - const tickets = await deps.clarificationRepository.list({ + const clarifications = await deps.clarificationRepository.list({ workspaceId, statuses, limit, offset, ...(viewerId ? { viewerId, includeServiceOwned: isOwner } : {}), }) - return context.json({ items: tickets.map(ticket => ticket.toData()) }, 200) + return context.json({ items: clarifications.map(clarification => clarification.toData()) }, 200) }) router.openapi(getClarificationRoute, async (context) => { const workspaceId = getWorkspaceId(context) const { clarificationId } = context.req.valid('param') - const ticket = await deps.clarificationRepository.load(clarificationId) - assertEntityInWorkspace(workspaceId, ticket.workspaceId, 'Clarification', clarificationId) - return context.json(ticket.toData(), 200) + const clarification = await deps.clarificationRepository.load(clarificationId) + assertEntityInWorkspace(workspaceId, clarification.workspaceId, 'Clarification', clarificationId) + return context.json(clarification.toData(), 200) }) router.openapi(answerClarificationRoute, async (context) => { @@ -235,8 +235,8 @@ export function createClarificationRouter(deps: ClarificationRouterDeps): OpenAP const { clarificationId } = context.req.valid('param') const body = context.req.valid('json') const userId = body.userId ?? getUserId(context) - const ticket = await deps.clarificationRepository.load(clarificationId) - assertEntityInWorkspace(workspaceId, ticket.workspaceId, 'Clarification', clarificationId) + const clarification = await deps.clarificationRepository.load(clarificationId) + assertEntityInWorkspace(workspaceId, clarification.workspaceId, 'Clarification', clarificationId) const selection = body.candidateId ? { kind: 'existing' as const, candidateId: body.candidateId } : { kind: 'custom' as const, description: body.customCandidate!.description } @@ -254,8 +254,8 @@ export function createClarificationRouter(deps: ClarificationRouterDeps): OpenAP const { clarificationId } = context.req.valid('param') const { proposalId, userId: bodyUserId } = context.req.valid('json') const userId = bodyUserId ?? getUserId(context) - const ticket = await deps.clarificationRepository.load(clarificationId) - assertEntityInWorkspace(workspaceId, ticket.workspaceId, 'Clarification', clarificationId) + const clarification = await deps.clarificationRepository.load(clarificationId) + assertEntityInWorkspace(workspaceId, clarification.workspaceId, 'Clarification', clarificationId) const applied = await deps.hitlService.markClarificationApplied(clarificationId, userId, proposalId) return context.json(applied.toData(), 200) }) @@ -265,8 +265,8 @@ export function createClarificationRouter(deps: ClarificationRouterDeps): OpenAP const { clarificationId } = context.req.valid('param') const { reason, userId: bodyUserId } = context.req.valid('json') const userId = bodyUserId ?? getUserId(context) - const ticket = await deps.clarificationRepository.load(clarificationId) - assertEntityInWorkspace(workspaceId, ticket.workspaceId, 'Clarification', clarificationId) + const clarification = await deps.clarificationRepository.load(clarificationId) + assertEntityInWorkspace(workspaceId, clarification.workspaceId, 'Clarification', clarificationId) const skipped = await deps.hitlService.skipClarification(clarificationId, reason, userId) return context.json(skipped.toData(), 200) }) diff --git a/packages/server/src/routes/skillInputOptions.ts b/packages/server/src/routes/skillInputOptions.ts index 35355f5..f35fcbb 100644 --- a/packages/server/src/routes/skillInputOptions.ts +++ b/packages/server/src/routes/skillInputOptions.ts @@ -168,14 +168,14 @@ async function resolveClarification( clarificationRepository: ClarificationRepository, ): Promise { const status = typeof filter.status === 'string' ? filter.status : undefined - const tickets = await clarificationRepository.list({ + const clarifications = await clarificationRepository.list({ workspaceId, ...(status ? { statuses: [status] as never } : {}), }) - return tickets.map(ticket => ({ - value: ticket.id, - label: truncate(ticket.question, 80), - description: ticket.status, + return clarifications.map(clarification => ({ + value: clarification.id, + label: truncate(clarification.question, 80), + description: clarification.status, })) } diff --git a/packages/server/test/routes.test.ts b/packages/server/test/routes.test.ts index ff2f937..89eb59a 100644 --- a/packages/server/test/routes.test.ts +++ b/packages/server/test/routes.test.ts @@ -221,7 +221,7 @@ describe('GET /workspaces/:ws/proposals', () => { }) describe('POST /workspaces/:ws/clarifications/:id/answer', () => { - it('returns 404 when the ticket does not exist', async () => { + it('returns 404 when the clarification does not exist', async () => { const { app } = await buildTestApp() const response = await app.request(`/workspaces/${workspaceId}/clarifications/missing/answer`, { @@ -257,7 +257,7 @@ describe('POST /workspaces/:ws/clarifications/:id/answer', () => { expect(response.status).toBe(400) }) - it('appends a custom candidate to the ticket and answers with it in one round-trip', async () => { + it('appends a custom candidate to the clarification and answers with it in one round-trip', async () => { const { app, deps } = await buildTestApp() await deps.clarificationRepository.save(makeClarification(workspaceId, { id: 'ct-custom', @@ -284,7 +284,7 @@ describe('POST /workspaces/:ws/clarifications/:id/answer', () => { }) describe('PATCH /workspaces/:ws/clarifications/:id', () => { - it('moves an answered ticket to applied and stamps proposalId', async () => { + it('moves an answered clarification to applied and stamps proposalId', async () => { const { app, deps } = await buildTestApp() await deps.clarificationRepository.save(makeClarification(workspaceId, { id: 'ct-link', status: 'answered' })) @@ -304,7 +304,7 @@ describe('PATCH /workspaces/:ws/clarifications/:id', () => { expect(reloaded.proposalId).toBe('p-99') }) - it('moves an answered ticket to applied without proposalId for no-impact resolutions', async () => { + it('moves an answered clarification to applied without proposalId for no-impact resolutions', async () => { const { app, deps } = await buildTestApp() await deps.clarificationRepository.save(makeClarification(workspaceId, { id: 'ct-noop', status: 'answered' })) @@ -337,7 +337,7 @@ describe('PATCH /workspaces/:ws/clarifications/:id', () => { expect(response.status).toBe(400) }) - it('returns 409 when ticket has not been answered yet', async () => { + it('returns 409 when clarification has not been answered yet', async () => { const { app, deps } = await buildTestApp() await deps.clarificationRepository.save(makeClarification(workspaceId, { id: 'ct-pending', status: 'pending' })) @@ -352,7 +352,7 @@ describe('PATCH /workspaces/:ws/clarifications/:id', () => { }) describe('POST /workspaces/:ws/clarifications/:id/skip', () => { - it('marks the ticket as skipped', async () => { + it('marks the clarification as skipped', async () => { const { app, deps } = await buildTestApp() await deps.clarificationRepository.save(makeClarification(workspaceId, { id: 'ct-1', status: 'pending' })) diff --git a/packages/server/test/routes/showAll.test.ts b/packages/server/test/routes/showAll.test.ts index 990f3d2..c2b2d45 100644 --- a/packages/server/test/routes/showAll.test.ts +++ b/packages/server/test/routes/showAll.test.ts @@ -110,10 +110,10 @@ describe('GET /workspaces/:ws/proposals?showAll=', () => { }) describe('GET /workspaces/:ws/clarifications?showAll=', () => { - it('filters owner to their own pending tickets by default', async () => { + it('filters owner to their own pending clarifications by default', async () => { const { app, workspaceId, users } = await buildMultiUserApp() - const ownerTicketId = await submitClarification(app, workspaceId, users.owner, 'owner question?') - const maintainerTicketId = await submitClarification(app, workspaceId, users.maintainer, 'maintainer question?') + const ownerClarificationId = await submitClarification(app, workspaceId, users.owner, 'owner question?') + const maintainerClarificationId = await submitClarification(app, workspaceId, users.maintainer, 'maintainer question?') const response = await app.request( `/workspaces/${workspaceId}/clarifications?status=pending`, @@ -123,14 +123,14 @@ describe('GET /workspaces/:ws/clarifications?showAll=', () => { expect(response.status).toBe(200) const body = await readJson(response) const ids = body.items.map(t => t.id) - expect(ids).toContain(ownerTicketId) - expect(ids).not.toContain(maintainerTicketId) + expect(ids).toContain(ownerClarificationId) + expect(ids).not.toContain(maintainerClarificationId) }) - it('shows every pending ticket when the owner sets showAll=true', async () => { + it('shows every pending clarification when the owner sets showAll=true', async () => { const { app, workspaceId, users } = await buildMultiUserApp() - const ownerTicketId = await submitClarification(app, workspaceId, users.owner, 'owner question?') - const maintainerTicketId = await submitClarification(app, workspaceId, users.maintainer, 'maintainer question?') + const ownerClarificationId = await submitClarification(app, workspaceId, users.owner, 'owner question?') + const maintainerClarificationId = await submitClarification(app, workspaceId, users.maintainer, 'maintainer question?') const response = await app.request( `/workspaces/${workspaceId}/clarifications?status=pending&showAll=true`, @@ -140,13 +140,13 @@ describe('GET /workspaces/:ws/clarifications?showAll=', () => { expect(response.status).toBe(200) const body = await readJson(response) const ids = body.items.map(t => t.id).sort() - expect(ids).toEqual([ownerTicketId, maintainerTicketId].sort()) + expect(ids).toEqual([ownerClarificationId, maintainerClarificationId].sort()) }) it('silently falls back to mine-only when a non-owner sets showAll=true', async () => { const { app, workspaceId, users } = await buildMultiUserApp() await submitClarification(app, workspaceId, users.owner, 'owner question?') - const maintainerTicketId = await submitClarification(app, workspaceId, users.maintainer, 'maintainer question?') + const maintainerClarificationId = await submitClarification(app, workspaceId, users.maintainer, 'maintainer question?') const response = await app.request( `/workspaces/${workspaceId}/clarifications?status=pending&showAll=true`, @@ -156,6 +156,6 @@ describe('GET /workspaces/:ws/clarifications?showAll=', () => { expect(response.status).toBe(200) const body = await readJson(response) const ids = body.items.map(t => t.id) - expect(ids).toEqual([maintainerTicketId]) + expect(ids).toEqual([maintainerClarificationId]) }) }) diff --git a/packages/server/test/routes/skillInputOptions.test.ts b/packages/server/test/routes/skillInputOptions.test.ts index a80d96b..d00d9e9 100644 --- a/packages/server/test/routes/skillInputOptions.test.ts +++ b/packages/server/test/routes/skillInputOptions.test.ts @@ -47,7 +47,7 @@ describe('GET /workspaces/:ws/skill-input-options', () => { ]) }) - it('clarify returns tickets filtered by status', async () => { + it('clarify returns clarifications filtered by status', async () => { const { app, deps } = await buildTestApp() const candidate: ClarificationCandidate = { id: 'cc-1' as never, diff --git a/packages/studio/package.json b/packages/studio/package.json index d9bfbdf..1489f67 100644 --- a/packages/studio/package.json +++ b/packages/studio/package.json @@ -23,6 +23,7 @@ "@dagrejs/dagre": "^3.0.0", "@fontsource-variable/geist": "^5.3.0", "@fontsource-variable/geist-mono": "^5.3.0", + "@fontsource-variable/noto-sans-tc": "^5.3.0", "@radix-ui/react-dialog": "^1.1.23", "@radix-ui/react-label": "^2.1.15", "@radix-ui/react-slot": "^1.3.3", @@ -33,11 +34,14 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", "cmdk": "^1.1.1", + "i18next": "^26.3.6", + "i18next-icu": "^2.4.4", "lucide-react": "^1.28.0", "mermaid": "^11.16.0", "radix-ui": "^1.6.7", "react": "^19.2.8", "react-dom": "^19.2.8", + "react-i18next": "^17.0.11", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", "tailwind-merge": "^3.6.0", diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index ad31ea2..8023b60 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -2,6 +2,7 @@ import type { EdgeId, NodeId, ProposalId } from '@braidhq/schema' import type { Surface } from './components/CommandPalette' import { Settings2, Sparkles } from 'lucide-react' import { useCallback, useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' import { BatchInFlightBanner } from './components/BatchInFlightBanner' import { CommandPalette } from './components/CommandPalette' import { CreateWorkspaceWizard } from './components/CreateWorkspaceWizard' @@ -39,9 +40,10 @@ export function App() { } function BootScreen() { + const { t } = useTranslation() return (
- Loading… + {t('common.loading')}
) } @@ -269,17 +271,18 @@ function WorkspaceHeader({ workspaceId, activeSurface, onOpenDetails }: { activeSurface: Surface | null onOpenDetails: () => void }) { + const { t } = useTranslation() // Surface nav lives in the Sidebar's HERE section now. The header reports where you are, // workspace name plus optional surface, and hosts page-specific tools on the right. const surfaceLabel = activeSurface === 'actions' - ? 'Actions' + ? t('shell.surfaces.actions') : activeSurface === 'clarifications' - ? 'Clarifications' + ? t('shell.surfaces.clarifications') : activeSurface === 'proposals' - ? 'Proposals' + ? t('shell.surfaces.proposals') : activeSurface === 'history' - ? 'History' + ? t('shell.surfaces.history') : null return ( @@ -291,7 +294,7 @@ function WorkspaceHeader({ workspaceId, activeSurface, onOpenDetails }: { @@ -273,6 +278,7 @@ function useSourceBadges( providerKind: string, rawOptions: readonly SkillInputDynamicOption[], ): readonly DynamicOptionWithBadge[] { + const { t, i18n } = useTranslation() const isSourceProvider = providerKind === 'source' const sourceIds = useMemo(() => { if (!isSourceProvider) @@ -332,9 +338,9 @@ function useSourceBadges( return { ...opt, badge: { - text: relativeAgo(now, lastAt), + text: relativeAgo(now, lastAt, t), tone: 'fresh' as const, - title: `Last extracted ${new Date(lastAt).toLocaleString()}`, + title: t('actionInput.lastProcessed', { date: formatDateTimeIn(i18n.language, lastAt) }), }, } } @@ -342,17 +348,17 @@ function useSourceBadges( return { ...opt, badge: { - text: 'stale', + text: t('actionInput.staleBadge'), tone: 'stale' as const, title: lastAt - ? `Changed since last extract ${new Date(lastAt).toLocaleString()}` - : 'Changed since last extract', + ? t('actionInput.changedSinceWithDate', { date: formatDateTimeIn(i18n.language, lastAt) }) + : t('actionInput.changedSinceLabel'), }, } } return opt }) - }, [isSourceProvider, rawOptions, sourceIds, diffQueries, ledgerQueries]) + }, [isSourceProvider, rawOptions, sourceIds, diffQueries, ledgerQueries, t, i18n.language]) } interface DynamicOptionWithBadge extends SkillInputDynamicOption { @@ -364,24 +370,25 @@ interface DynamicOptionWithBadge extends SkillInputDynamicOption { } /** - * Compact "Nm ago", "Nh ago", or "Nd ago" formatter for the freshness chip. - * Avoids pulling in a date library for one badge. + * Compact freshness chip label. + * Keeps the terse "Nm ago" form rather than the long Intl phrasing, + * then resolves the words through the catalog so it localizes. */ -function relativeAgo(now: number, iso: string): string { +function relativeAgo(now: number, iso: string, t: TFunction): string { const then = Date.parse(iso) if (Number.isNaN(then)) - return 'recent' + return t('actionInput.freshness.recent') const delta = Math.max(0, now - then) - const m = Math.floor(delta / 60_000) - if (m < 1) - return 'just now' - if (m < 60) - return `${m}m ago` - const h = Math.floor(m / 60) - if (h < 24) - return `${h}h ago` - const d = Math.floor(h / 24) - return `${d}d ago` + const minutes = Math.floor(delta / 60_000) + if (minutes < 1) + return t('actionInput.freshness.justNow') + if (minutes < 60) + return t('actionInput.freshness.minutesAgo', { count: minutes }) + const hours = Math.floor(minutes / 60) + if (hours < 24) + return t('actionInput.freshness.hoursAgo', { count: hours }) + const days = Math.floor(hours / 24) + return t('actionInput.freshness.daysAgo', { count: days }) } function SelectControl({ diff --git a/packages/studio/src/components/AddSourceDialog.tsx b/packages/studio/src/components/AddSourceDialog.tsx index 58697da..7e6bb42 100644 --- a/packages/studio/src/components/AddSourceDialog.tsx +++ b/packages/studio/src/components/AddSourceDialog.tsx @@ -1,8 +1,11 @@ +import { localize } from '@braidhq/schema' import { useMutation } from '@tanstack/react-query' import { Loader2 } from 'lucide-react' import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' import { api } from '@/lib/api' import { humaniseApiError } from '@/lib/errors' +import { useLocale } from '@/lib/i18n' import { useOntology, useSourceLoaders } from '@/lib/queries' import { loaderKindLabel, nameToId, type SourceDraft, STUDIO_KNOWN_LOADER_KINDS, toSourceDescriptor } from '@/lib/sourceDraft' import { useGoogleOAuth } from '@/lib/useGoogleOAuth' @@ -22,6 +25,8 @@ interface AddSourceDialogProps { } export function AddSourceDialog({ workspaceId, open, onOpenChange, onAdded }: AddSourceDialogProps) { + const { t } = useTranslation() + const { locale } = useLocale() const sourceLoaders = useSourceLoaders() const ontology = useOntology(workspaceId) const roles = ontology.data?.sourceRoles ?? [] @@ -138,36 +143,36 @@ export function AddSourceDialog({ workspaceId, open, onOpenChange, onAdded }: Ad > - Add Source - Source rows are appended to PRODUCT.md and provisioned if a loader is set. + {t('sources.addDialog.title')} + {t('sources.addDialog.description')}
- + - + {/* Radix reserves the empty value, so a "none" sentinel maps to "". */}
- - setName(e.target.value)} placeholder={`${role || 'source'}-name`} autoFocus /> + + setName(e.target.value)} placeholder={t('sources.addDialog.namePlaceholder', { role: role || 'source' })} autoFocus />

./ {pathSegment} @@ -179,47 +184,47 @@ export function AddSourceDialog({ workspaceId, open, onOpenChange, onAdded }: Ad id="add-source-desc" value={description} onChange={setDescription} - label="What is this source?" - placeholder="e.g. what this source holds and how authoritative it is." - helperText="Visible to skills via PRODUCT.md." + label={t('sources.addDialog.descriptionFieldLabel')} + placeholder={t('sources.addDialog.descriptionPlaceholder')} + helperText={t('sources.addDialog.descriptionHint')} rows={2} /> {loaderKind === 'git' && (

- + setGitUrl(e.target.value)} placeholder="https://github.com/org/repo.git" /> - + setGitBranch(e.target.value)} placeholder="master" />
)} {loaderKind === 'gdrive' && ( <> - + setGdriveFolderId(e.target.value)} placeholder="1abc…" /> {gdriveFolderId.trim() === 'root' && (

- "root" mirrors your entire My Drive (rejected by the loader). Create a dedicated subfolder and paste its ID. + {t('sources.addDialog.googleDriveRootWarning')}

)}
- + setGdriveInclude(e.target.value)} placeholder="^docs/" /> - + setGdriveExclude(e.target.value)} placeholder="\\.tmp$" />
-

Google Account

+

{t('sources.addDialog.googleAccountTitle')}

{oauthConnected - ? `Connected for source "${sourceId}". Re-renaming will require re-connecting.` - : 'Connect a Google account that has read access to the folder above.'} + ? t('sources.addDialog.googleDriveConnected', { sourceId }) + : t('sources.addDialog.googleDriveConnectHint')}

{startOauth.error && ( @@ -241,15 +246,15 @@ export function AddSourceDialog({ workspaceId, open, onOpenChange, onAdded }: Ad {loaderKind === 'github' && ( <>
- + setGithubOwner(e.target.value)} placeholder="anthropics" /> - + setGithubRepo(e.target.value)} placeholder="claude-code" />
- + - + setGithubLabels(e.target.value)} placeholder="bug, p1" />

- Auth: server reads + {t('sources.addDialog.githubAuthPrefix')} {' '} $GH_TOKEN {' '} - at sync time. Without one you get GitHub's 60 req/h anonymous rate limit. + {t('sources.addDialog.githubAuthSuffix')}

)} @@ -283,15 +288,15 @@ export function AddSourceDialog({ workspaceId, open, onOpenChange, onAdded }: Ad kind={loaderKind} hint={( <> - This loader plugin is registered on the server but Studio does not ship a per-field config for it. To use it, edit + {t('sources.addDialog.unknownLoaderHintPrefix')} {' '} PRODUCT.md {' '} - directly and add the + {t('sources.addDialog.unknownLoaderHintMiddle')} {' '} {loaderKind} {' '} - config under this source. + {t('sources.addDialog.unknownLoaderHintSuffix')} )} /> @@ -299,10 +304,10 @@ export function AddSourceDialog({ workspaceId, open, onOpenChange, onAdded }: Ad {add.error &&

{humaniseApiError(add.error)}

}
- + diff --git a/packages/studio/src/components/BatchInFlightBanner.tsx b/packages/studio/src/components/BatchInFlightBanner.tsx index aa8ac28..f41def8 100644 --- a/packages/studio/src/components/BatchInFlightBanner.tsx +++ b/packages/studio/src/components/BatchInFlightBanner.tsx @@ -1,4 +1,5 @@ import { AlertCircle, CheckCircle2, Sparkles } from 'lucide-react' +import { useTranslation } from 'react-i18next' import { useBatchStatus } from '@/lib/queries' import { TopBanner } from './TopBanner' import { Button } from './ui/button' @@ -17,6 +18,7 @@ type Mode = // Cross-surface entry to the Batch view. // Hidden on the Batch surface itself, and when no plan exists. export function BatchInFlightBanner({ workspaceId, onOpenBatch, suppress }: BatchInFlightBannerProps) { + const { t } = useTranslation() const { data: plan } = useBatchStatus(workspaceId ?? undefined) // `archived` is the user's explicit "I'm done seeing this" signal, // so the banner stays hidden until a new plan kicks off. @@ -29,9 +31,9 @@ export function BatchInFlightBanner({ workspaceId, onOpenBatch, suppress }: Batc let mode: Mode if (plan.status === 'running') - mode = { kind: 'active', label: 'Bootstrap Running', completed, total } + mode = { kind: 'active', label: t('review.banners.bootstrapRunning'), completed, total } else if (plan.status === 'deriving') - mode = { kind: 'active', label: 'Deriving Units…', completed, total } + mode = { kind: 'active', label: t('review.banners.derivingUnits'), completed, total } else if ((plan.status === 'failed' || plan.status === 'stopped') && unfinished) mode = { kind: 'resumable', completed, total } else @@ -50,16 +52,9 @@ export function BatchInFlightBanner({ workspaceId, onOpenBatch, suppress }: Batc tone="batch" label={mode.label} detail={mode.total > 0 - ? ( - <> - {mode.completed} - {' / '} - {mode.total} - {' units'} - - ) + ? t('review.banners.unitsProgress', { completed: mode.completed, total: mode.total }) : ''} - actions={actions('View Progress')} + actions={actions(t('review.banners.viewProgressButton'))} /> ) } @@ -68,17 +63,10 @@ export function BatchInFlightBanner({ workspaceId, onOpenBatch, suppress }: Batc return ( - {mode.completed} - {' / '} - {mode.total} - {' units done'} - - )} - actions={actions('Resume')} + detail={t('review.banners.unitsDone', { completed: mode.completed, total: mode.total })} + actions={actions(t('review.banners.resumeButton'))} /> ) } @@ -86,18 +74,11 @@ export function BatchInFlightBanner({ workspaceId, onOpenBatch, suppress }: Batc return ( - {mode.completed} - {' / '} - {mode.total} - {' units'} - - )} - actions={actions('View Report')} + detail={t('review.banners.unitsProgress', { completed: mode.completed, total: mode.total })} + actions={actions(t('review.banners.viewReportButton'))} /> ) } diff --git a/packages/studio/src/components/CommandPalette.tsx b/packages/studio/src/components/CommandPalette.tsx index aaaa968..563505b 100644 --- a/packages/studio/src/components/CommandPalette.tsx +++ b/packages/studio/src/components/CommandPalette.tsx @@ -1,6 +1,7 @@ import type { SkillManifest, Workspace } from '@braidhq/schema' import { Activity, ClipboardCheck, GitGraph, HelpCircle, Network, Settings, Settings2, Sparkles } from 'lucide-react' import { useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' import { CommandDialog, CommandEmpty, @@ -24,13 +25,6 @@ interface CommandPaletteProps { export type Surface = 'actions' | 'activity' | 'batch' | 'clarifications' | 'history' | 'proposals' | 'settings' -interface SurfaceItem { - id: Surface | null - label: string - Icon: typeof Sparkles - shortcut: string -} - type ChordTarget = { kind: 'surface', surface: Surface | null } | { kind: 'workspace-details' } function chordSecondKey(key: string): ChordTarget | undefined { @@ -47,15 +41,16 @@ function chordSecondKey(key: string): ChordTarget | undefined { } } -const SURFACE_ITEMS: SurfaceItem[] = [ - { id: null, label: 'Graph (home)', Icon: Network, shortcut: 'G G' }, - { id: 'actions', label: 'Actions', Icon: Sparkles, shortcut: 'G A' }, - { id: 'clarifications', label: 'Clarifications', Icon: HelpCircle, shortcut: 'G C' }, - { id: 'proposals', label: 'Proposals', Icon: ClipboardCheck, shortcut: 'G P' }, - { id: 'activity', label: 'Activity', Icon: Activity, shortcut: 'G B' }, - { id: 'history', label: 'History', Icon: GitGraph, shortcut: 'G H' }, - { id: 'settings', label: 'Settings', Icon: Settings, shortcut: 'G S' }, -] +// `as const` keeps labelKey literal so t() validates each against the typed catalog. +const SURFACE_ITEMS = [ + { id: null, labelKey: 'shell.commandPalette.graphHome', Icon: Network, shortcut: 'G G' }, + { id: 'actions', labelKey: 'shell.surfaces.actions', Icon: Sparkles, shortcut: 'G A' }, + { id: 'clarifications', labelKey: 'shell.surfaces.clarifications', Icon: HelpCircle, shortcut: 'G C' }, + { id: 'proposals', labelKey: 'shell.surfaces.proposals', Icon: ClipboardCheck, shortcut: 'G P' }, + { id: 'activity', labelKey: 'shell.surfaces.activity', Icon: Activity, shortcut: 'G B' }, + { id: 'history', labelKey: 'shell.surfaces.history', Icon: GitGraph, shortcut: 'G H' }, + { id: 'settings', labelKey: 'shell.surfaces.settings', Icon: Settings, shortcut: 'G S' }, +] as const satisfies readonly { id: Surface | null, labelKey: string, Icon: typeof Sparkles, shortcut: string }[] function isTypingTarget(target: EventTarget | null): boolean { if (!(target instanceof HTMLElement)) @@ -76,6 +71,7 @@ export function CommandPalette({ onSelectSurface, onOpenWorkspaceDetails, }: CommandPaletteProps) { + const { t } = useTranslation() const [open, setOpen] = useState(false) const { data: skillData } = useSkills(activeWorkspaceId ?? undefined) @@ -133,13 +129,13 @@ export function CommandPalette({ const skills = (skillData?.items ?? []).filter((s: SkillManifest) => !s.frontmatter.braid.hidden) return ( - - + + - No results. + {t('shell.commandPalette.noMatches')} - - {SURFACE_ITEMS.map(({ id, label, Icon, shortcut }) => ( + + {SURFACE_ITEMS.map(({ id, labelKey, Icon, shortcut }) => ( { @@ -149,7 +145,7 @@ export function CommandPalette({ disabled={(id !== 'settings' && !activeWorkspaceId) || activeSurface === id} > - {label} + {t(labelKey)} {shortcut} ))} @@ -162,14 +158,14 @@ export function CommandPalette({ }} > - Workspace Settings + {t('shell.commandPalette.workspaceSettings')} G W )} {workspaces.length > 0 && ( - + {workspaces.map(ws => ( 0 && ( - + {skills.map((skill: SkillManifest) => ( void } -const WIZARD_ERROR_CASES: readonly ErrorCase[] = [ - { - match: e => e.status === 400 && e.message.includes('already exists'), - message: 'A workspace with that name already exists. Open it from the sidebar, or delete it first to recreate.', - }, -] - const STEP_ORDER: StepKey[] = ['basics', 'sources', 'mcp', 'advanced', 'confirm', 'progress'] -const STEP_LABELS: Record = { - basics: 'Basics', - sources: 'Sources', - mcp: 'MCP Servers', - advanced: 'Advanced', - confirm: 'Review', - progress: 'Creating', +const STEP_LABEL_KEYS: Record = { + basics: 'workspace.wizard.stepBasics', + sources: 'workspace.wizard.stepSources', + mcp: 'workspace.wizard.stepMcp', + advanced: 'workspace.wizard.stepAdvanced', + confirm: 'workspace.wizard.stepConfirm', + progress: 'workspace.wizard.stepProgress', } export function CreateWorkspaceWizard({ open, onOpenChange, onCreated }: CreateWorkspaceWizardProps) { + const { t } = useTranslation() const queryClient = useQueryClient() const [step, setStep] = useState('basics') const [name, setName] = useState('') @@ -135,12 +133,12 @@ export function CreateWorkspaceWizard({ open, onOpenChange, onCreated }: CreateW > - Create Workspace + {t('workspace.wizard.createTitle')} - Scaffolds a fresh workspace under + {t('workspace.wizard.createDescriptionPrefix')} {' '} ~/.braid/workspaces/ - . To open an existing one, pick it from the sidebar. + {t('workspace.wizard.createDescriptionSuffix')} @@ -215,10 +213,10 @@ export function CreateWorkspaceWizard({ open, onOpenChange, onCreated }: CreateW disabled={STEP_ORDER.indexOf(step) === 0} > - Back + {t('common.back')}
- + {step === 'confirm' ? ( ) : ( )} @@ -247,6 +245,7 @@ export function CreateWorkspaceWizard({ open, onOpenChange, onCreated }: CreateW } function StepIndicator({ step }: { step: StepKey }) { + const { t } = useTranslation() const current = STEP_ORDER.indexOf(step) return (
    @@ -262,7 +261,7 @@ function StepIndicator({ step }: { step: StepKey }) { > {index + 1} - {STEP_LABELS[key]} + {t(STEP_LABEL_KEYS[key])} {index < STEP_ORDER.length - 2 && } ) @@ -279,32 +278,33 @@ function BasicsStep({ name, description, onName, onDescription }: { onName: (v: string) => void onDescription: (v: string) => void }) { + const { t } = useTranslation() const invalid = name.length > 0 && !WORKSPACE_NAME_PATTERN.test(name) return (
    - + onName(e.target.value)} /> -

    Lowercase letters, digits, and dashes. Name conflicts are rejected; delete the existing workspace first to reuse a name.

    +

    {t('workspace.wizard.nameHint')}

    ~/.braid/workspaces/ {name || ''} {invalid && ( -

    Name must start with a letter or digit and use only lowercase letters, digits, or dashes.

    +

    {t('workspace.wizard.nameInvalid')}

    )}
    ) @@ -318,6 +318,8 @@ function SourcesStep({ workspaceName, roles, sources, oauthConnectedFor, onChang onChange: (sources: SourceDraft[]) => void onOauthConnected: (sourceId: string) => void }) { + const { t } = useTranslation() + const { locale } = useLocale() function add(role: SourceRoleDescriptor) { onChange([...sources, defaultSourceDraft(role)]) } @@ -331,12 +333,11 @@ function SourcesStep({ workspaceName, roles, sources, oauthConnectedFor, onChang return (

    - Add a source for each role the ontology declares. A loader places files - under the workspace folder; pick + {t('workspace.wizard.sourcesDescriptionPrefix')} {' '} manual {' '} - to manage that path yourself. You can also skip this step and add sources later. + {t('workspace.wizard.sourcesDescriptionSuffix')}

    {sources.map(source => ( @@ -355,9 +356,7 @@ function SourcesStep({ workspaceName, roles, sources, oauthConnectedFor, onChang {roles.map(role => ( ))}
    @@ -373,6 +372,7 @@ function SourceRow({ workspaceName, draft, oauthConnected, onUpdate, onRemove, o onRemove: () => void onOauthConnected: (sourceId: string) => void }) { + const { t } = useTranslation() const id = nameToId(draft.name) const targetPath = `./${draftPathSegment(draft)}/${id || ''}` return ( @@ -380,7 +380,7 @@ function SourceRow({ workspaceName, draft, oauthConnected, onUpdate, onRemove, o
    {draft.role} onUpdate({ name: e.target.value })} className="flex-1" @@ -397,9 +397,9 @@ function SourceRow({ workspaceName, draft, oauthConnected, onUpdate, onRemove, o id={`src-desc-${draft.uiId}`} value={draft.description} onChange={next => onUpdate({ description: next })} - label="What is this source?" - placeholder="e.g. what this source holds and how authoritative it is." - helperText="Visible to skills via PRODUCT.md." + label={t('workspace.wizard.sourceDescriptionLabel')} + placeholder={t('workspace.wizard.sourceDescriptionPlaceholder')} + helperText={t('workspace.wizard.sourceDescriptionHint')} rows={2} /> @@ -413,7 +413,7 @@ function SourceRow({ workspaceName, draft, oauthConnected, onUpdate, onRemove, o className="flex-1" /> onUpdate({ gitBranch: e.target.value })} className="w-28" @@ -423,18 +423,18 @@ function SourceRow({ workspaceName, draft, oauthConnected, onUpdate, onRemove, o {draft.loaderKind === 'gdrive' && ( <> onUpdate({ gdriveFolderId: e.target.value })} />
    onUpdate({ gdriveInclude: e.target.value })} /> onUpdate({ gdriveExclude: e.target.value })} /> @@ -452,15 +452,15 @@ function SourceRow({ workspaceName, draft, oauthConnected, onUpdate, onRemove, o kind={draft.loaderKind} hint={( <> - This loader plugin is registered on the server but Studio does not ship a per-field config for it. Scaffold the workspace with this source set to + {t('workspace.wizard.unknownLoaderHintBefore')} {' '} manual {' '} - and add the + {t('workspace.wizard.unknownLoaderHintMiddle')} {' '} {draft.loaderKind} {' '} - config to PRODUCT.md afterwards. + {t('workspace.wizard.unknownLoaderHintAfter')} )} /> @@ -479,6 +479,7 @@ function SourceRow({ workspaceName, draft, oauthConnected, onUpdate, onRemove, o * It just tells the workspace the source has no auto-sync. */ function LoaderSelect({ value, onChange }: { value: string, onChange: (kind: string) => void }) { + const { t } = useTranslation() const { data } = useSourceLoaders() return ( ) @@ -500,6 +501,7 @@ function GdriveOauthBlock({ workspaceName, sourceName, connected, onConnected }: connected: boolean onConnected: (sourceId: string) => void }) { + const { t } = useTranslation() // Token storage key is `${workspaceId}--${sourceId}`. // Workspace id is the typed name, the PRODUCT.md name, // source id is derived from the source name. @@ -515,13 +517,13 @@ function GdriveOauthBlock({ workspaceName, sourceName, connected, onConnected }:
    -

    Google Account

    +

    {t('workspace.wizard.googleAccount')}

    {!canStart - ? 'Set workspace name and source name first.' + ? t('workspace.wizard.googleAccountSetNames') : connected - ? `Connected for "${workspaceId}/${sourceId}".` - : 'Authorise read access to the folder above. Required before the workspace is created.'} + ? t('workspace.wizard.googleAccountConnected', { workspaceId, sourceId }) + : t('workspace.wizard.googleAccountAuthorise')}

    {startOauth.error && ( @@ -544,6 +546,7 @@ function McpStep({ servers, onChange }: { servers: McpDraft[] onChange: (servers: McpDraft[]) => void }) { + const { t } = useTranslation() function add() { onChange([...servers, { uiId: crypto.randomUUID(), id: '', url: '', description: '', headersText: '' }]) } @@ -557,21 +560,21 @@ function McpStep({ servers, onChange }: { return (

    - Optional. MCP endpoints the agent can call during extract / validate (e.g. Linear, Redmine, Jira) to fill gaps in your intent / code sources; they are not provisioned as content sources themselves. Only Streamable HTTP transport is supported. Use + {t('workspace.wizard.mcpDescriptionPrefix')} {' '} $ {'{ENV_VAR}'} {' '} - in header values for secrets (resolved at runtime, never written to PRODUCT.md). + {t('workspace.wizard.mcpDescriptionSuffix')}

    {servers.map(server => (
    update(server.uiId, { id: e.target.value })} className="w-40" @@ -590,9 +593,9 @@ function McpStep({ servers, onChange }: { id={`mcp-desc-${server.uiId}`} value={server.description} onChange={next => update(server.uiId, { description: next })} - label="What does this MCP serve?" - placeholder="e.g. Linear, source of truth for tickets." - helperText="Visible to skills via PRODUCT.md." + label={t('workspace.wizard.mcpDescriptionLabel')} + placeholder={t('workspace.wizard.mcpDescriptionPlaceholder')} + helperText={t('workspace.wizard.sourceDescriptionHint')} rows={2} />