From f0b1ae543a53b4c456edb4230d7baad55395ae87 Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 12 Mar 2026 09:28:19 -0700 Subject: [PATCH 01/22] Compute trace-based quest and campaign completion --- docs/canonical/ROADMAP_PROTOCOL.md | 3 +- docs/canonical/TRACEABILITY.md | 3 + src/cli/commands/dashboard.ts | 48 +++++++ src/cli/commands/show.ts | 21 ++- src/domain/models/dashboard.ts | 21 +++ src/domain/services/ReadinessService.ts | 26 +++- src/domain/services/TraceabilityAnalysis.ts | 102 +++++++++++++ src/infrastructure/GraphContext.ts | 118 +++++++++++++-- src/tui/bijou/views/roadmap-view.ts | 4 + src/tui/render-status.ts | 136 +++++++++++++++++- .../GraphContextEntityDetail.test.ts | 8 ++ test/unit/DashboardTraceCommand.test.ts | 20 +++ test/unit/ReadinessService.test.ts | 28 ++++ test/unit/TraceabilityAnalysis.test.ts | 88 ++++++++++++ 14 files changed, 603 insertions(+), 23 deletions(-) diff --git a/docs/canonical/ROADMAP_PROTOCOL.md b/docs/canonical/ROADMAP_PROTOCOL.md index e9c456d..5b6fc98 100644 --- a/docs/canonical/ROADMAP_PROTOCOL.md +++ b/docs/canonical/ROADMAP_PROTOCOL.md @@ -6,7 +6,7 @@ - **READY**: Passed readiness validation and entered the executable work DAG. - **IN_PROGRESS**: Claimed by a worker from `READY`. - **BLOCKED**: Executable work blocked by an incomplete dependency. -- **DONE**: Acceptance criteria met, evidence attached. +- **DONE**: Acceptance criteria met, evidence attached. For governed traced work this is computed from criteria and evidence; legacy untracked work continues to honor manual status until it gains a traceability packet. - **GRAVEYARD**: Rejected or abandoned. > **Note:** `normalizeQuestStatus()` in `Quest.ts` remaps legacy graph values on read: `INBOX` → `BACKLOG`. New code writes canonical status values directly. @@ -20,6 +20,7 @@ - `spike` quests additionally require at least one linked `note:*`, `spec:*`, or `adr:*` node documenting investigative framing. - `claim` is valid only from `READY`. - `PLANNED` quests may carry draft dependencies, estimates, and traceability links, but they are excluded from executable frontier / critical-path analysis. +- `show` / `context` inspect the readiness contract for `PLANNED` and already-active quests; the `ready` transition itself still requires `PLANNED`. ## Authoring Workflow - Use `xyph shape ` while a quest is `BACKLOG` or `PLANNED` to enrich durable metadata such as `description` and `task_kind`. diff --git a/docs/canonical/TRACEABILITY.md b/docs/canonical/TRACEABILITY.md index cead03f..2efe745 100644 --- a/docs/canonical/TRACEABILITY.md +++ b/docs/canonical/TRACEABILITY.md @@ -30,6 +30,9 @@ A task is DONE when: 2. The governing Policy's conditions are satisfied (campaign-level gates) `status: DONE` becomes a **computed property**, not a manually-set flag. +For backward compatibility, legacy work with no traceability packet remains +`UNTRACKED` and continues to honor its manual status until requirements and +criteria are modeled. ## 3. New Node Types diff --git a/src/cli/commands/dashboard.ts b/src/cli/commands/dashboard.ts index 358d3c1..3f452e5 100644 --- a/src/cli/commands/dashboard.ts +++ b/src/cli/commands/dashboard.ts @@ -197,6 +197,40 @@ export function registerDashboardCommands(program: Command, ctx: CliContext): vo const untestedCriteria = computeUntestedCriteria(critSummaries); const failingCriteria = computeFailingCriteria(critSummaries); const coverage = computeCoverageRatio(critSummaries); + const questCompletion = snapshot.quests + .filter((quest) => quest.computedCompletion?.tracked || quest.computedCompletion?.discrepancy) + .map((quest) => ({ + id: quest.id, + title: quest.title, + manualStatus: quest.status, + computedCompletion: quest.computedCompletion, + })); + const campaignCompletion = snapshot.campaigns + .filter((campaign) => campaign.computedCompletion?.tracked || campaign.computedCompletion?.discrepancy) + .map((campaign) => ({ + id: campaign.id, + title: campaign.title, + manualStatus: campaign.status, + computedCompletion: campaign.computedCompletion, + })); + const questDiscrepancies = questCompletion + .filter((entry) => entry.computedCompletion?.discrepancy) + .map((entry) => ({ + id: entry.id, + title: entry.title, + manualStatus: entry.manualStatus, + discrepancy: entry.computedCompletion?.discrepancy, + verdict: entry.computedCompletion?.verdict, + })); + const campaignDiscrepancies = campaignCompletion + .filter((entry) => entry.computedCompletion?.discrepancy) + .map((entry) => ({ + id: entry.id, + title: entry.title, + manualStatus: entry.manualStatus, + discrepancy: entry.computedCompletion?.discrepancy, + verdict: entry.computedCompletion?.verdict, + })); if (ctx.json) { ctx.jsonOut({ @@ -219,10 +253,20 @@ export function registerDashboardCommands(program: Command, ctx: CliContext): vo linkedOnly: coverage.linkedOnly, unevidenced: coverage.unevidenced, coverageRatio: coverage.ratio, + computedCompleteQuests: questCompletion.filter((entry) => entry.computedCompletion?.complete).length, + computedTrackedQuests: questCompletion.filter((entry) => entry.computedCompletion?.tracked).length, + computedCompleteCampaigns: campaignCompletion.filter((entry) => entry.computedCompletion?.complete).length, + computedTrackedCampaigns: campaignCompletion.filter((entry) => entry.computedCompletion?.tracked).length, + questDiscrepancies: questDiscrepancies.length, + campaignDiscrepancies: campaignDiscrepancies.length, }, unmetRequirements: unmetReqs, untestedCriteria, failingCriteria, + questCompletion, + campaignCompletion, + questDiscrepancies, + campaignDiscrepancies, }, }); return; @@ -239,6 +283,10 @@ export function registerDashboardCommands(program: Command, ctx: CliContext): vo untestedCriteria, failingCriteria, coverage, + questCompletion, + campaignCompletion, + questDiscrepancies, + campaignDiscrepancies, }, ctx.style)); break; } diff --git a/src/cli/commands/show.ts b/src/cli/commands/show.ts index 47dd842..4b38405 100644 --- a/src/cli/commands/show.ts +++ b/src/cli/commands/show.ts @@ -124,7 +124,10 @@ function renderQuestDetail(detail: QuestDetail, readiness?: ReadinessAssessment) lines.push(` dependsOn: ${quest.dependsOn?.join(', ')}`); } if (readiness) { - lines.push(` readiness: ${readiness.valid ? 'READYABLE' : 'NOT READY'}`); + const readinessLabel = readiness.valid + ? (quest.status === 'PLANNED' ? 'READYABLE' : 'CONTRACT SATISFIED') + : 'NOT READY'; + lines.push(` readiness: ${readinessLabel}`); if (!readiness.valid && readiness.unmet.length > 0) { for (const unmet of readiness.unmet) { lines.push(` - ${unmet.message}`); @@ -139,6 +142,20 @@ function renderQuestDetail(detail: QuestDetail, readiness?: ReadinessAssessment) lines.push(` criteria: ${detail.criteria.length}`); lines.push(` evidence: ${detail.evidence.length}`); lines.push(` policies: ${detail.policies.length}`); + if (quest.computedCompletion) { + lines.push(''); + lines.push('Computed Completion'); + lines.push(` verdict: ${quest.computedCompletion.verdict}`); + lines.push(` complete: ${quest.computedCompletion.complete ? 'yes' : 'no'}`); + lines.push(` tracked: ${quest.computedCompletion.tracked ? 'yes' : 'no'}`); + lines.push(` coverage: ${Math.round(quest.computedCompletion.coverageRatio * 100)}%`); + if (quest.computedCompletion.policyId) { + lines.push(` policy: ${quest.computedCompletion.policyId}`); + } + if (quest.computedCompletion.discrepancy) { + lines.push(` discrepancy: ${quest.computedCompletion.discrepancy}`); + } + } if (detail.submission) { lines.push(''); @@ -214,7 +231,7 @@ export function registerShowCommands(program: Command, ctx: CliContext): void { if (detail.questDetail) { const { WarpRoadmapAdapter } = await import('../../infrastructure/adapters/WarpRoadmapAdapter.js'); const { ReadinessService } = await import('../../domain/services/ReadinessService.js'); - readiness = await new ReadinessService(new WarpRoadmapAdapter(ctx.graphPort)).assess(id); + readiness = await new ReadinessService(new WarpRoadmapAdapter(ctx.graphPort)).assess(id, { transition: false }); } if (ctx.json) { diff --git a/src/domain/models/dashboard.ts b/src/domain/models/dashboard.ts index 8c46184..785b8b0 100644 --- a/src/domain/models/dashboard.ts +++ b/src/domain/models/dashboard.ts @@ -14,6 +14,25 @@ import type { LayerScore } from '../services/analysis/types.js'; export type { ApprovalGateStatus }; export type CampaignStatus = 'BACKLOG' | 'IN_PROGRESS' | 'DONE' | 'UNKNOWN'; +export type ComputedCompletionVerdict = 'UNTRACKED' | 'SATISFIED' | 'FAILED' | 'LINKED' | 'MISSING'; +export type CompletionDiscrepancyCode = + | 'MANUAL_DONE_BUT_COMPUTED_INCOMPLETE' + | 'MANUAL_NOT_DONE_BUT_COMPUTED_COMPLETE'; + +export interface ComputedCompletionSummary { + tracked: boolean; + complete: boolean; + verdict: ComputedCompletionVerdict; + requirementCount: number; + criterionCount: number; + coverageRatio: number; + satisfiedCount: number; + failingCriterionIds: string[]; + linkedOnlyCriterionIds: string[]; + missingCriterionIds: string[]; + policyId?: string; + discrepancy?: CompletionDiscrepancyCode; +} export interface CampaignNode { id: string; @@ -21,6 +40,7 @@ export interface CampaignNode { status: CampaignStatus; description?: string; dependsOn?: string[]; + computedCompletion?: ComputedCompletionSummary; } export interface QuestNode { @@ -50,6 +70,7 @@ export interface QuestNode { reopenedAt?: number; // Task dependencies (Weaver) dependsOn?: string[]; + computedCompletion?: ComputedCompletionSummary; } export interface IntentNode { diff --git a/src/domain/services/ReadinessService.ts b/src/domain/services/ReadinessService.ts index f1dd7be..1a9764e 100644 --- a/src/domain/services/ReadinessService.ts +++ b/src/domain/services/ReadinessService.ts @@ -27,10 +27,17 @@ export interface ReadinessAssessment { unmet: ReadinessCondition[]; } +export interface ReadinessAssessmentOptions { + transition?: boolean; +} + export class ReadinessService { constructor(private readonly roadmap: RoadmapQueryPort) {} - public async assess(questId: string): Promise { + public async assess( + questId: string, + options?: ReadinessAssessmentOptions, + ): Promise { const quest = await this.roadmap.getQuest(questId); if (quest === null) { return { @@ -51,12 +58,27 @@ export class ReadinessService { ))?.to; const unmet: ReadinessCondition[] = []; - if (quest.status !== 'PLANNED') { + const transition = options?.transition ?? true; + const statusAllowsContractInspection = ( + quest.status === 'PLANNED' || + quest.status === 'READY' || + quest.status === 'IN_PROGRESS' || + quest.status === 'BLOCKED' || + quest.status === 'DONE' + ); + + if (transition && quest.status !== 'PLANNED') { unmet.push({ code: 'invalid-status', field: 'status', message: `READY requires status PLANNED, quest ${questId} is ${quest.status}`, }); + } else if (!transition && !statusAllowsContractInspection) { + unmet.push({ + code: 'invalid-status', + field: 'status', + message: `Readiness contract applies to planned or active work, quest ${questId} is ${quest.status}`, + }); } if (!intentId) { unmet.push({ diff --git a/src/domain/services/TraceabilityAnalysis.ts b/src/domain/services/TraceabilityAnalysis.ts index d3f99cc..e5bfd59 100644 --- a/src/domain/services/TraceabilityAnalysis.ts +++ b/src/domain/services/TraceabilityAnalysis.ts @@ -8,6 +8,10 @@ */ import type { EvidenceResult } from '../entities/Evidence.js'; +import type { + CompletionDiscrepancyCode, + ComputedCompletionSummary, +} from '../models/dashboard.js'; // --------------------------------------------------------------------------- // Input types (match dashboard model shapes) @@ -36,6 +40,13 @@ export interface CriterionVerdictSummary { verdict: CriterionVerdict; } +export interface PolicySummary { + id: string; + coverageThreshold: number; + requireAllCriteria: boolean; + requireEvidence: boolean; +} + // --------------------------------------------------------------------------- // Unmet requirements — reqs with criteria that lack passing evidence // --------------------------------------------------------------------------- @@ -214,3 +225,94 @@ export function computeCoverageRatio( ratio: satisfied / criteria.length, }; } + +function computeDiscrepancy( + manualComplete: boolean, + computedComplete: boolean, +): CompletionDiscrepancyCode | undefined { + if (manualComplete && !computedComplete) { + return 'MANUAL_DONE_BUT_COMPUTED_INCOMPLETE'; + } + if (!manualComplete && computedComplete) { + return 'MANUAL_NOT_DONE_BUT_COMPUTED_COMPLETE'; + } + return undefined; +} + +export function computeCompletionSummary( + requirements: RequirementSummary[], + criteria: CriterionSummary[], + options?: { + policy?: PolicySummary; + manualComplete?: boolean; + }, +): ComputedCompletionSummary { + const coverage = computeCoverageRatio(criteria); + const verdicts = computeCriterionVerdicts(criteria); + const failingCriterionIds = verdicts + .filter((entry) => entry.verdict === 'FAILED') + .map((entry) => entry.id); + const linkedOnlyCriterionIds = verdicts + .filter((entry) => entry.verdict === 'LINKED') + .map((entry) => entry.id); + const missingCriterionIds = verdicts + .filter((entry) => entry.verdict === 'MISSING') + .map((entry) => entry.id); + + const tracked = requirements.length > 0 || criteria.length > 0; + const policy = options?.policy; + const manualComplete = options?.manualComplete ?? false; + const criterionCount = criteria.length; + const requirementCount = requirements.length; + + let complete = false; + let verdict: ComputedCompletionSummary['verdict'] = 'UNTRACKED'; + + if (!tracked) { + verdict = 'UNTRACKED'; + complete = manualComplete; + } else if (criterionCount === 0) { + verdict = 'MISSING'; + } else if (failingCriterionIds.length > 0) { + verdict = 'FAILED'; + } else if (policy) { + const meetsCoverage = coverage.ratio >= policy.coverageThreshold; + const blocksForLinked = policy.requireAllCriteria && linkedOnlyCriterionIds.length > 0; + const blocksForMissing = (policy.requireAllCriteria || policy.requireEvidence) && missingCriterionIds.length > 0; + + if (blocksForLinked) { + verdict = 'LINKED'; + } else if (blocksForMissing) { + verdict = 'MISSING'; + } else if (meetsCoverage) { + verdict = 'SATISFIED'; + complete = true; + } else if (linkedOnlyCriterionIds.length > 0) { + verdict = 'LINKED'; + } else { + verdict = 'MISSING'; + } + } else if (linkedOnlyCriterionIds.length > 0) { + verdict = 'LINKED'; + } else if (missingCriterionIds.length > 0) { + verdict = 'MISSING'; + } else { + verdict = 'SATISFIED'; + complete = true; + } + + return { + tracked, + complete, + verdict, + requirementCount, + criterionCount, + coverageRatio: coverage.ratio, + satisfiedCount: coverage.satisfied, + failingCriterionIds, + linkedOnlyCriterionIds, + missingCriterionIds, + policyId: policy?.id, + discrepancy: tracked ? computeDiscrepancy(manualComplete, complete) : undefined, + }; +} diff --git a/src/infrastructure/GraphContext.ts b/src/infrastructure/GraphContext.ts index 9321f04..10a5e2c 100644 --- a/src/infrastructure/GraphContext.ts +++ b/src/infrastructure/GraphContext.ts @@ -63,6 +63,7 @@ import type { RequirementKind, RequirementPriority } from '../domain/entities/Re import { VALID_REQUIREMENT_KINDS, VALID_REQUIREMENT_PRIORITIES } from '../domain/entities/Requirement.js'; import type { EvidenceKind, EvidenceResult } from '../domain/entities/Evidence.js'; import { VALID_EVIDENCE_KINDS, VALID_EVIDENCE_RESULTS } from '../domain/entities/Evidence.js'; +import { computeCompletionSummary } from '../domain/services/TraceabilityAnalysis.js'; import { DEFAULT_POLICY_ALLOW_MANUAL_SEAL, DEFAULT_POLICY_COVERAGE_THRESHOLD, @@ -384,19 +385,6 @@ class GraphContextImpl implements GraphContext { }); } - const questsByCampaignId = new Map(); - for (const quest of quests) { - if (!quest.campaignId) continue; - const members = questsByCampaignId.get(quest.campaignId) ?? []; - members.push(quest); - questsByCampaignId.set(quest.campaignId, members); - } - for (const campaign of campaigns) { - const memberQuests = questsByCampaignId.get(campaign.id); - if (!memberQuests || memberQuests.length === 0) continue; - campaign.status = deriveCampaignStatusFromQuests(memberQuests); - } - // --- Build intents --- const intents: IntentNode[] = []; for (const n of intentNodes) { @@ -696,6 +684,110 @@ class GraphContextImpl implements GraphContext { }); } + // --- Compute traceability rollups for quests and campaigns --- + const policyByCampaignId = new Map(); + for (const policy of policies) { + if (!policy.campaignId || policyByCampaignId.has(policy.campaignId)) continue; + policyByCampaignId.set(policy.campaignId, policy); + } + + const requirementsByQuestId = new Map(); + for (const requirement of requirements) { + for (const taskId of requirement.taskIds) { + const linked = requirementsByQuestId.get(taskId) ?? []; + linked.push(requirement); + requirementsByQuestId.set(taskId, linked); + } + } + const criteriaByRequirementId = new Map(); + for (const criterion of criteria) { + if (!criterion.requirementId) continue; + const linked = criteriaByRequirementId.get(criterion.requirementId) ?? []; + linked.push(criterion); + criteriaByRequirementId.set(criterion.requirementId, linked); + } + + for (const quest of quests) { + const questRequirements = requirementsByQuestId.get(quest.id) ?? []; + const questCriteria = questRequirements.flatMap((requirement) => criteriaByRequirementId.get(requirement.id) ?? []); + const appliedPolicy = quest.campaignId ? policyByCampaignId.get(quest.campaignId) : undefined; + quest.computedCompletion = computeCompletionSummary( + questRequirements.map((requirement) => ({ + id: requirement.id, + criterionIds: requirement.criterionIds, + })), + questCriteria.map((criterion) => ({ + id: criterion.id, + evidence: criterion.evidenceIds + .map((evidenceId) => evidence.find((entry) => entry.id === evidenceId)) + .filter((entry): entry is EvidenceNode => Boolean(entry)) + .map((entry) => ({ + id: entry.id, + result: entry.result, + producedAt: entry.producedAt, + })), + })), + { + policy: appliedPolicy + ? { + id: appliedPolicy.id, + coverageThreshold: appliedPolicy.coverageThreshold, + requireAllCriteria: appliedPolicy.requireAllCriteria, + requireEvidence: appliedPolicy.requireEvidence, + } + : undefined, + manualComplete: quest.status === 'DONE', + }, + ); + } + + const questsByCampaignId = new Map(); + for (const quest of quests) { + if (!quest.campaignId) continue; + const members = questsByCampaignId.get(quest.campaignId) ?? []; + members.push(quest); + questsByCampaignId.set(quest.campaignId, members); + } + for (const campaign of campaigns) { + const memberQuests = questsByCampaignId.get(campaign.id); + if (memberQuests && memberQuests.length > 0) { + campaign.status = deriveCampaignStatusFromQuests(memberQuests); + } + + const questIds = new Set((memberQuests ?? []).map((quest) => quest.id)); + const campaignRequirements = requirements.filter((requirement) => requirement.taskIds.some((taskId) => questIds.has(taskId))); + const campaignCriteria = campaignRequirements.flatMap((requirement) => criteriaByRequirementId.get(requirement.id) ?? []); + const appliedPolicy = policyByCampaignId.get(campaign.id); + campaign.computedCompletion = computeCompletionSummary( + campaignRequirements.map((requirement) => ({ + id: requirement.id, + criterionIds: requirement.criterionIds, + })), + campaignCriteria.map((criterion) => ({ + id: criterion.id, + evidence: criterion.evidenceIds + .map((evidenceId) => evidence.find((entry) => entry.id === evidenceId)) + .filter((entry): entry is EvidenceNode => Boolean(entry)) + .map((entry) => ({ + id: entry.id, + result: entry.result, + producedAt: entry.producedAt, + })), + })), + { + policy: appliedPolicy + ? { + id: appliedPolicy.id, + coverageThreshold: appliedPolicy.coverageThreshold, + requireAllCriteria: appliedPolicy.requireAllCriteria, + requireEvidence: appliedPolicy.requireEvidence, + } + : undefined, + manualComplete: campaign.status === 'DONE', + }, + ); + } + // --- Build suggestions (M11 Phase 4) --- log('Building suggestion models…'); const suggestions: SuggestionNode[] = []; diff --git a/src/tui/bijou/views/roadmap-view.ts b/src/tui/bijou/views/roadmap-view.ts index 5c6db31..926d4d8 100644 --- a/src/tui/bijou/views/roadmap-view.ts +++ b/src/tui/bijou/views/roadmap-view.ts @@ -253,6 +253,10 @@ export function roadmapView(model: DashboardModel, style: StylePort, width?: num lines.push(` ${q.title.slice(0, pw - 2)}`); lines.push(''); lines.push(` Status: ${style.styledStatus(q.status)}`); + if (q.computedCompletion) { + lines.push(` Trace: ${q.computedCompletion.verdict}${q.computedCompletion.discrepancy ? ' (!) mismatch' : ''}`); + lines.push(` Done?: ${q.computedCompletion.complete ? 'yes' : 'no'} coverage=${Math.round(q.computedCompletion.coverageRatio * 100)}%`); + } if (q.hours !== undefined) lines.push(` Hours: ${q.hours}`); if (q.assignedTo) lines.push(` Owner: ${q.assignedTo}`); diff --git a/src/tui/render-status.ts b/src/tui/render-status.ts index d8643bd..3b52b51 100644 --- a/src/tui/render-status.ts +++ b/src/tui/render-status.ts @@ -3,6 +3,7 @@ import { } from '@flyingrobots/bijou'; import type { CriterionNode, + ComputedCompletionSummary, EvidenceNode, GraphSnapshot, PolicyNode, @@ -24,6 +25,25 @@ function snapshotHeader(style: StylePort, label: string, detail: string, borderT }); } +function renderCompletionBadge(summary: ComputedCompletionSummary | undefined): string { + if (!summary || !summary.tracked) { + return badge('—', { variant: 'muted' }); + } + switch (summary.verdict) { + case 'SATISFIED': + return badge('SAT', { variant: 'success' }); + case 'FAILED': + return badge('FAIL', { variant: 'error' }); + case 'LINKED': + return badge('LINK', { variant: 'warning' }); + case 'MISSING': + return badge('MISS', { variant: 'warning' }); + case 'UNTRACKED': + default: + return badge('—', { variant: 'muted' }); + } +} + /** * Renders quests grouped by campaign — the Roadmap view. */ @@ -49,23 +69,31 @@ export function renderRoadmap(snapshot: GraphSnapshot, style: StylePort): string for (const [key, quests] of grouped) { const heading = campaignTitle.get(key) ?? key; lines.push(''); - lines.push(style.styled(style.theme.ui.sectionHeader, ` ${heading}`)); + const campaign = snapshot.campaigns.find((entry) => entry.id === key); + const campaignDelta = campaign?.computedCompletion?.discrepancy + ? ` ${style.styled(style.theme.semantic.warning, '(!)')}` + : ''; + lines.push(style.styled(style.theme.ui.sectionHeader, ` ${heading}`) + campaignDelta); const rows = quests.map(q => [ style.styled(style.theme.semantic.muted, q.id.slice(0, 20)), - q.title.slice(0, 42), + q.title.slice(0, 34), badge(q.status, { variant: statusVariant(q.status) }), + renderCompletionBadge(q.computedCompletion), String(q.hours), - q.assignedTo ?? '—', + q.computedCompletion?.discrepancy + ? style.styled(style.theme.semantic.warning, '!') + : (q.assignedTo ?? '—'), ]); lines.push(table({ columns: [ { header: 'Quest', width: 22 }, - { header: 'Title', width: 44 }, + { header: 'Title', width: 36 }, { header: 'Status', width: 13 }, + { header: 'Trace', width: 8 }, { header: 'h', width: 5 }, - { header: 'Assigned', width: 16 }, + { header: 'Assigned', width: 12 }, ], rows, headerToken: style.theme.ui.tableHeader, @@ -657,6 +685,32 @@ export interface TraceViewData { untestedCriteria: string[]; failingCriteria: string[]; coverage: CoverageResult; + questCompletion: { + id: string; + title: string; + manualStatus: string; + computedCompletion?: ComputedCompletionSummary; + }[]; + campaignCompletion: { + id: string; + title: string; + manualStatus: string; + computedCompletion?: ComputedCompletionSummary; + }[]; + questDiscrepancies: { + id: string; + title: string; + manualStatus: string; + discrepancy?: string; + verdict?: string; + }[]; + campaignDiscrepancies: { + id: string; + title: string; + manualStatus: string; + discrepancy?: string; + verdict?: string; + }[]; } /** @@ -797,6 +851,76 @@ export function renderTrace(data: TraceViewData, style: StylePort): string { })); } + if (data.questCompletion.length > 0) { + lines.push(''); + lines.push(separator({ label: 'Quest Completion', borderToken: style.theme.border.secondary })); + const rows = data.questCompletion.map((entry) => [ + style.styled(style.theme.semantic.muted, entry.id.slice(0, 20)), + entry.title.slice(0, 30), + badge(entry.manualStatus, { variant: statusVariant(entry.manualStatus) }), + renderCompletionBadge(entry.computedCompletion), + entry.computedCompletion?.discrepancy + ? style.styled(style.theme.semantic.warning, '!') + : style.styled(style.theme.semantic.muted, '—'), + ]); + lines.push(table({ + columns: [ + { header: 'Quest', width: 22 }, + { header: 'Title', width: 32 }, + { header: 'Manual', width: 12 }, + { header: 'Computed', width: 10 }, + { header: 'Δ', width: 3 }, + ], + rows, + headerToken: style.theme.ui.tableHeader, + borderToken: style.theme.border.primary, + })); + } + + if (data.campaignCompletion.length > 0) { + lines.push(''); + lines.push(separator({ label: 'Campaign Completion', borderToken: style.theme.border.secondary })); + const rows = data.campaignCompletion.map((entry) => [ + style.styled(style.theme.semantic.muted, entry.id.slice(0, 20)), + entry.title.slice(0, 28), + badge(entry.manualStatus, { variant: statusVariant(entry.manualStatus) }), + renderCompletionBadge(entry.computedCompletion), + entry.computedCompletion?.discrepancy + ? style.styled(style.theme.semantic.warning, '!') + : style.styled(style.theme.semantic.muted, '—'), + ]); + lines.push(table({ + columns: [ + { header: 'Campaign', width: 22 }, + { header: 'Title', width: 30 }, + { header: 'Manual', width: 12 }, + { header: 'Computed', width: 10 }, + { header: 'Δ', width: 3 }, + ], + rows, + headerToken: style.theme.ui.tableHeader, + borderToken: style.theme.border.primary, + })); + } + + if (data.questDiscrepancies.length > 0) { + lines.push(''); + lines.push(separator({ label: 'Quest Discrepancies', borderToken: style.theme.border.warning })); + const items = data.questDiscrepancies.map((entry) => ( + `${entry.id} manual=${entry.manualStatus} computed=${entry.verdict ?? '—'} ${entry.discrepancy ?? ''}` + )); + lines.push(enumeratedList(items, { style: 'arabic', indent: 4 })); + } + + if (data.campaignDiscrepancies.length > 0) { + lines.push(''); + lines.push(separator({ label: 'Campaign Discrepancies', borderToken: style.theme.border.warning })); + const items = data.campaignDiscrepancies.map((entry) => ( + `${entry.id} manual=${entry.manualStatus} computed=${entry.verdict ?? '—'} ${entry.discrepancy ?? ''}` + )); + lines.push(enumeratedList(items, { style: 'arabic', indent: 4 })); + } + // --- Summary --- lines.push(''); lines.push(separator({ label: 'Summary', borderToken: style.theme.border.primary })); @@ -810,6 +934,8 @@ export function renderTrace(data: TraceViewData, style: StylePort): string { lines.push(` ${style.styled(style.theme.semantic.muted, 'Linked Only:')} ${data.coverage.linkedOnly}`); lines.push(` ${style.styled(style.theme.semantic.muted, 'Unevidenced:')} ${data.coverage.unevidenced}`); lines.push(` ${style.styled(style.theme.semantic.muted, 'Coverage:')} ${pct}`); + lines.push(` ${style.styled(style.theme.semantic.muted, 'Quest Discrepancies:')} ${data.questDiscrepancies.length}`); + lines.push(` ${style.styled(style.theme.semantic.muted, 'Campaign Discrepancies:')} ${data.campaignDiscrepancies.length}`); return lines.join('\n'); } diff --git a/test/integration/GraphContextEntityDetail.test.ts b/test/integration/GraphContextEntityDetail.test.ts index d98dceb..ac7085f 100644 --- a/test/integration/GraphContextEntityDetail.test.ts +++ b/test/integration/GraphContextEntityDetail.test.ts @@ -159,6 +159,14 @@ describe('GraphContext entity detail integration', () => { } expect(questDetail.quest.status).toBe('READY'); expect(questDetail.quest.taskKind).toBe('delivery'); + expect(questDetail.quest.computedCompletion).toMatchObject({ + tracked: true, + complete: false, + verdict: 'LINKED', + discrepancy: undefined, + requirementCount: 1, + criterionCount: 1, + }); expect(questDetail.requirements.map((entry) => entry.id)).toEqual(['req:SHOW']); expect(questDetail.criteria.map((entry) => entry.id)).toEqual(['criterion:SHOW']); expect(questDetail.evidence.map((entry) => entry.id)).toEqual(['evidence:SHOW']); diff --git a/test/unit/DashboardTraceCommand.test.ts b/test/unit/DashboardTraceCommand.test.ts index 3a42ec2..ac03905 100644 --- a/test/unit/DashboardTraceCommand.test.ts +++ b/test/unit/DashboardTraceCommand.test.ts @@ -120,10 +120,20 @@ describe('dashboard trace view JSON', () => { linkedOnly: 0, unevidenced: 0, coverageRatio: 1, + computedCompleteQuests: 0, + computedTrackedQuests: 0, + computedCompleteCampaigns: 0, + computedTrackedCampaigns: 0, + questDiscrepancies: 0, + campaignDiscrepancies: 0, }, unmetRequirements: [], untestedCriteria: [], failingCriteria: [], + questCompletion: [], + campaignCompletion: [], + questDiscrepancies: [], + campaignDiscrepancies: [], }, }); }); @@ -194,6 +204,12 @@ describe('dashboard trace view JSON', () => { linkedOnly: 1, unevidenced: 0, coverageRatio: 0, + computedCompleteQuests: 0, + computedTrackedQuests: 0, + computedCompleteCampaigns: 0, + computedTrackedCampaigns: 0, + questDiscrepancies: 0, + campaignDiscrepancies: 0, }), unmetRequirements: [{ id: 'req:TRACE', @@ -202,6 +218,10 @@ describe('dashboard trace view JSON', () => { }], untestedCriteria: ['criterion:LINKED'], failingCriteria: ['criterion:FAILED'], + questCompletion: [], + campaignCompletion: [], + questDiscrepancies: [], + campaignDiscrepancies: [], }), })); }); diff --git a/test/unit/ReadinessService.test.ts b/test/unit/ReadinessService.test.ts index dd9eb7c..648977f 100644 --- a/test/unit/ReadinessService.test.ts +++ b/test/unit/ReadinessService.test.ts @@ -128,6 +128,34 @@ describe('ReadinessService', () => { expect(assessment.valid).toBe(true); }); + it('treats READY quests as satisfying the readiness contract when inspected outside the transition command', async () => { + const svc = new ReadinessService(makePort( + makeQuest({ + status: 'READY', + }), + { + 'task:READY-001': [ + { type: 'authorized-by', to: 'intent:READY' }, + { type: 'belongs-to', to: 'campaign:READY' }, + { type: 'implements', to: 'req:READY-001' }, + ], + 'req:READY-001': [ + { type: 'has-criterion', to: 'criterion:READY-001' }, + ], + }, + { + 'req:READY-001': [ + { type: 'decomposes-to', from: 'story:READY-001' }, + ], + }, + )); + + const assessment = await svc.assess('task:READY-001', { transition: false }); + + expect(assessment.valid).toBe(true); + expect(assessment.unmet).toEqual([]); + }); + it('reports missing quests without throwing', async () => { const svc = new ReadinessService(makePort(null)); diff --git a/test/unit/TraceabilityAnalysis.test.ts b/test/unit/TraceabilityAnalysis.test.ts index 8988a8c..f805acd 100644 --- a/test/unit/TraceabilityAnalysis.test.ts +++ b/test/unit/TraceabilityAnalysis.test.ts @@ -4,6 +4,7 @@ import { computeFailingCriteria, computeUntestedCriteria, computeCoverageRatio, + computeCompletionSummary, computeCriterionVerdicts, type RequirementSummary, type CriterionSummary, @@ -220,6 +221,93 @@ describe('computeCoverageRatio', () => { }); }); +describe('computeCompletionSummary', () => { + it('marks tracked work complete only when all criteria are satisfied by default', () => { + const reqs: RequirementSummary[] = [{ id: 'req:A', criterionIds: ['criterion:A'] }]; + const criteria: CriterionSummary[] = [ + { + id: 'criterion:A', + evidence: [{ id: 'evidence:A', result: 'pass', producedAt: 100 }], + }, + ]; + + expect(computeCompletionSummary(reqs, criteria, { manualComplete: true })).toEqual({ + tracked: true, + complete: true, + verdict: 'SATISFIED', + requirementCount: 1, + criterionCount: 1, + coverageRatio: 1, + satisfiedCount: 1, + failingCriterionIds: [], + linkedOnlyCriterionIds: [], + missingCriterionIds: [], + policyId: undefined, + discrepancy: undefined, + }); + }); + + it('flags manual DONE as discrepant when criteria are only linked', () => { + const reqs: RequirementSummary[] = [{ id: 'req:A', criterionIds: ['criterion:A'] }]; + const criteria: CriterionSummary[] = [ + { + id: 'criterion:A', + evidence: [{ id: 'evidence:A', result: 'linked', producedAt: 100 }], + }, + ]; + + const result = computeCompletionSummary(reqs, criteria, { manualComplete: true }); + expect(result.complete).toBe(false); + expect(result.verdict).toBe('LINKED'); + expect(result.discrepancy).toBe('MANUAL_DONE_BUT_COMPUTED_INCOMPLETE'); + expect(result.linkedOnlyCriterionIds).toEqual(['criterion:A']); + }); + + it('marks governed work complete when policy threshold is met without requiring all criteria', () => { + const reqs: RequirementSummary[] = [{ id: 'req:A', criterionIds: ['criterion:A', 'criterion:B'] }]; + const criteria: CriterionSummary[] = [ + { + id: 'criterion:A', + evidence: [{ id: 'evidence:A', result: 'pass', producedAt: 100 }], + }, + { + id: 'criterion:B', + evidence: [{ id: 'evidence:B', result: 'linked', producedAt: 200 }], + }, + ]; + + const result = computeCompletionSummary(reqs, criteria, { + manualComplete: false, + policy: { + id: 'policy:TRACE', + coverageThreshold: 0.5, + requireAllCriteria: false, + requireEvidence: false, + }, + }); + expect(result.complete).toBe(true); + expect(result.verdict).toBe('SATISFIED'); + expect(result.discrepancy).toBe('MANUAL_NOT_DONE_BUT_COMPUTED_COMPLETE'); + expect(result.policyId).toBe('policy:TRACE'); + }); + + it('treats untracked work as incomplete without a discrepancy when not manually done', () => { + const result = computeCompletionSummary([], [], { manualComplete: false }); + expect(result.complete).toBe(false); + expect(result.tracked).toBe(false); + expect(result.verdict).toBe('UNTRACKED'); + expect(result.discrepancy).toBeUndefined(); + }); + + it('honors manual completion for untracked work without surfacing a discrepancy', () => { + const result = computeCompletionSummary([], [], { manualComplete: true }); + expect(result.complete).toBe(true); + expect(result.tracked).toBe(false); + expect(result.verdict).toBe('UNTRACKED'); + expect(result.discrepancy).toBeUndefined(); + }); +}); + describe('computeCriterionVerdicts', () => { it('prefers the latest fail over earlier passes and linked observations', () => { const criteria: CriterionSummary[] = [ From 70a2e8275ed10bb1db6be28f9ac10ac525a4f0f0 Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 12 Mar 2026 11:40:14 -0700 Subject: [PATCH 02/22] Gate settlement on governed completion --- docs/canonical/ROADMAP_PROTOCOL.md | 1 + docs/canonical/TRACEABILITY.md | 3 + src/cli/commands/artifact.ts | 16 ++ src/cli/commands/submission.ts | 21 ++ src/domain/services/SettlementGateService.ts | 206 +++++++++++++++++++ test/unit/SettlementGateService.test.ts | 131 ++++++++++++ test/unit/SignedSettlementCommands.test.ts | 168 +++++++++++++++ 7 files changed, 546 insertions(+) create mode 100644 src/domain/services/SettlementGateService.ts create mode 100644 test/unit/SettlementGateService.test.ts diff --git a/docs/canonical/ROADMAP_PROTOCOL.md b/docs/canonical/ROADMAP_PROTOCOL.md index 5b6fc98..7cf39e2 100644 --- a/docs/canonical/ROADMAP_PROTOCOL.md +++ b/docs/canonical/ROADMAP_PROTOCOL.md @@ -21,6 +21,7 @@ - `claim` is valid only from `READY`. - `PLANNED` quests may carry draft dependencies, estimates, and traceability links, but they are excluded from executable frontier / critical-path analysis. - `show` / `context` inspect the readiness contract for `PLANNED` and already-active quests; the `ready` transition itself still requires `PLANNED`. +- `seal` and auto-sealing `merge` must reject governed work when the applied policy disallows manual settlement and computed completion is still incomplete. ## Authoring Workflow - Use `xyph shape ` while a quest is `BACKLOG` or `PLANNED` to enrich durable metadata such as `description` and `task_kind`. diff --git a/docs/canonical/TRACEABILITY.md b/docs/canonical/TRACEABILITY.md index 2efe745..59cc7f5 100644 --- a/docs/canonical/TRACEABILITY.md +++ b/docs/canonical/TRACEABILITY.md @@ -139,6 +139,9 @@ allows nodes to move between campaigns without identity conflicts. **Phase 2 — Criteria & Evidence:** `criterion:` and `evidence:` nodes, `has-criterion` and `verifies` edges, `xyph scan` command. **Phase 3 — Computed Status:** DONE as graph query, Policy nodes, Definition of Done enforcement. +This phase also gates settlement: `seal` and auto-sealing `merge` reject governed +work when required criteria are missing, linked-only, or failing unless the +applied policy explicitly permits manual settlement. **Phase 4 — Intelligence:** Gap detection ("what's untested?"), risk/assumption tracking, suggested tests from unverified criteria. diff --git a/src/cli/commands/artifact.ts b/src/cli/commands/artifact.ts index 4638f60..67064cd 100644 --- a/src/cli/commands/artifact.ts +++ b/src/cli/commands/artifact.ts @@ -1,6 +1,11 @@ import type { Command } from 'commander'; import type { CliContext } from '../context.js'; import { createErrorHandler } from '../errorHandler.js'; +import { + assessSettlementGate, + formatSettlementGateFailure, + settlementGateFailureData, +} from '../../domain/services/SettlementGateService.js'; export const UNSIGNED_SCROLLS_OVERRIDE_ENV = 'XYPH_ALLOW_UNSIGNED_SCROLLS'; @@ -49,10 +54,21 @@ export function registerArtifactCommands(program: Command, ctx: CliContext): voi .action(withErrorHandler(async (id: string, opts: { artifact: string; rationale: string }) => { const { GuildSealService } = await import('../../domain/services/GuildSealService.js'); const { FsKeyringAdapter } = await import('../../infrastructure/adapters/FsKeyringAdapter.js'); + const { createGraphContext } = await import('../../infrastructure/GraphContext.js'); const keyring = new FsKeyringAdapter(); const sealService = new GuildSealService(keyring); const allowUnsignedScrolls = allowUnsignedScrollsForSettlement(); + const graphCtx = createGraphContext(ctx.graphPort); + const detail = await graphCtx.fetchEntityDetail(id); + const assessment = assessSettlementGate(detail?.questDetail, 'seal'); + if (!assessment.allowed) { + return ctx.failWithData( + formatSettlementGateFailure(assessment), + settlementGateFailureData(assessment), + ); + } + // Guard: warn if a non-terminal submission exists for this quest let openSubWarning: string | undefined; try { diff --git a/src/cli/commands/submission.ts b/src/cli/commands/submission.ts index d036bca..55615bc 100644 --- a/src/cli/commands/submission.ts +++ b/src/cli/commands/submission.ts @@ -9,6 +9,11 @@ import { formatUnsignedScrollOverrideWarning, missingSettlementKeyData, } from './artifact.js'; +import { + assessSettlementGate, + formatSettlementGateFailure, + settlementGateFailureData, +} from '../../domain/services/SettlementGateService.js'; export function registerSubmissionCommands(program: Command, ctx: CliContext): void { const withErrorHandler = createErrorHandler(ctx); @@ -191,6 +196,7 @@ export function registerSubmissionCommands(program: Command, ctx: CliContext): v const { GitWorkspaceAdapter } = await import('../../infrastructure/adapters/GitWorkspaceAdapter.js'); const { GuildSealService } = await import('../../domain/services/GuildSealService.js'); const { FsKeyringAdapter } = await import('../../infrastructure/adapters/FsKeyringAdapter.js'); + const { createGraphContext } = await import('../../infrastructure/GraphContext.js'); const adapter = new WarpSubmissionAdapter(ctx.graphPort, ctx.agentId); const service = new SubmissionService(adapter); @@ -201,6 +207,21 @@ export function registerSubmissionCommands(program: Command, ctx: CliContext): v const questStatus = questId ? await adapter.getQuestStatus(questId) : null; const shouldAutoSeal = typeof questId === 'string' && questStatus !== 'DONE'; + if (shouldAutoSeal && questId) { + const graphCtx = createGraphContext(ctx.graphPort); + const detail = await graphCtx.fetchEntityDetail(questId); + const assessment = assessSettlementGate(detail?.questDetail, 'merge'); + if (!assessment.allowed) { + return ctx.failWithData( + formatSettlementGateFailure(assessment), + { + submissionId, + ...settlementGateFailureData(assessment), + }, + ); + } + } + if (shouldAutoSeal && !sealService.hasPrivateKey(ctx.agentId) && !allowUnsignedScrolls) { return ctx.failWithData( formatMissingSettlementKeyMessage(ctx.agentId, 'merge'), diff --git a/src/domain/services/SettlementGateService.ts b/src/domain/services/SettlementGateService.ts new file mode 100644 index 0000000..d51144a --- /dev/null +++ b/src/domain/services/SettlementGateService.ts @@ -0,0 +1,206 @@ +import type { ComputedCompletionVerdict, QuestDetail } from '../models/dashboard.js'; + +export type SettlementAction = 'seal' | 'merge'; + +export type SettlementBlockCode = + | 'quest-not-found' + | 'missing-computed-completion' + | 'governed-work-untracked' + | 'governed-work-missing-evidence' + | 'governed-work-linked-only' + | 'governed-work-failing-evidence'; + +export interface SettlementGateAssessment { + allowed: boolean; + questId: string; + governed: boolean; + action: SettlementAction; + policyId?: string; + allowManualSeal?: boolean; + tracked?: boolean; + complete?: boolean; + verdict?: ComputedCompletionVerdict; + code?: SettlementBlockCode; + requirementCount?: number; + criterionCount?: number; + coverageRatio?: number; + failingCriterionIds: string[]; + linkedOnlyCriterionIds: string[]; + missingCriterionIds: string[]; +} + +function blockCodeForVerdict( + verdict: ComputedCompletionVerdict | undefined, +): SettlementBlockCode { + switch (verdict) { + case 'FAILED': + return 'governed-work-failing-evidence'; + case 'LINKED': + return 'governed-work-linked-only'; + case 'MISSING': + return 'governed-work-missing-evidence'; + case 'UNTRACKED': + default: + return 'governed-work-untracked'; + } +} + +export function assessSettlementGate( + detail: QuestDetail | null | undefined, + action: SettlementAction, +): SettlementGateAssessment { + if (!detail) { + return { + allowed: false, + questId: '(unknown)', + governed: false, + action, + code: 'quest-not-found', + failingCriterionIds: [], + linkedOnlyCriterionIds: [], + missingCriterionIds: [], + }; + } + + const questId = detail.quest.id; + const computed = detail.quest.computedCompletion; + const appliedPolicy = detail.policies.find((policy) => policy.id === computed?.policyId) + ?? detail.policies[0]; + + if (!appliedPolicy) { + return { + allowed: true, + questId, + governed: false, + action, + tracked: computed?.tracked, + complete: computed?.complete, + verdict: computed?.verdict, + failingCriterionIds: computed?.failingCriterionIds ?? [], + linkedOnlyCriterionIds: computed?.linkedOnlyCriterionIds ?? [], + missingCriterionIds: computed?.missingCriterionIds ?? [], + }; + } + + if (appliedPolicy.allowManualSeal) { + return { + allowed: true, + questId, + governed: true, + action, + policyId: appliedPolicy.id, + allowManualSeal: true, + tracked: computed?.tracked, + complete: computed?.complete, + verdict: computed?.verdict, + requirementCount: computed?.requirementCount, + criterionCount: computed?.criterionCount, + coverageRatio: computed?.coverageRatio, + failingCriterionIds: computed?.failingCriterionIds ?? [], + linkedOnlyCriterionIds: computed?.linkedOnlyCriterionIds ?? [], + missingCriterionIds: computed?.missingCriterionIds ?? [], + }; + } + + if (!computed) { + return { + allowed: false, + questId, + governed: true, + action, + policyId: appliedPolicy.id, + allowManualSeal: false, + code: 'missing-computed-completion', + failingCriterionIds: [], + linkedOnlyCriterionIds: [], + missingCriterionIds: [], + }; + } + + if (computed.complete) { + return { + allowed: true, + questId, + governed: true, + action, + policyId: appliedPolicy.id, + allowManualSeal: false, + tracked: computed.tracked, + complete: computed.complete, + verdict: computed.verdict, + requirementCount: computed.requirementCount, + criterionCount: computed.criterionCount, + coverageRatio: computed.coverageRatio, + failingCriterionIds: computed.failingCriterionIds, + linkedOnlyCriterionIds: computed.linkedOnlyCriterionIds, + missingCriterionIds: computed.missingCriterionIds, + }; + } + + return { + allowed: false, + questId, + governed: true, + action, + policyId: appliedPolicy.id, + allowManualSeal: false, + tracked: computed.tracked, + complete: computed.complete, + verdict: computed.verdict, + code: blockCodeForVerdict(computed.verdict), + requirementCount: computed.requirementCount, + criterionCount: computed.criterionCount, + coverageRatio: computed.coverageRatio, + failingCriterionIds: computed.failingCriterionIds, + linkedOnlyCriterionIds: computed.linkedOnlyCriterionIds, + missingCriterionIds: computed.missingCriterionIds, + }; +} + +export function formatSettlementGateFailure( + assessment: SettlementGateAssessment, +): string { + if (assessment.code === 'quest-not-found') { + return `Cannot ${assessment.action}: quest detail could not be resolved from the graph.`; + } + if (assessment.code === 'missing-computed-completion') { + return `Cannot ${assessment.action} ${assessment.questId}: governed work is missing computed completion state for policy ${assessment.policyId}.`; + } + + const verdict = assessment.verdict ?? 'UNKNOWN'; + const parts: string[] = [ + `Cannot ${assessment.action} ${assessment.questId}: policy ${assessment.policyId} blocks settlement while computed completion is ${verdict}.`, + ]; + if (assessment.missingCriterionIds.length > 0) { + parts.push(`Missing criteria: ${assessment.missingCriterionIds.join(', ')}`); + } + if (assessment.linkedOnlyCriterionIds.length > 0) { + parts.push(`Linked-only criteria: ${assessment.linkedOnlyCriterionIds.join(', ')}`); + } + if (assessment.failingCriterionIds.length > 0) { + parts.push(`Failing criteria: ${assessment.failingCriterionIds.join(', ')}`); + } + return parts.join(' '); +} + +export function settlementGateFailureData( + assessment: SettlementGateAssessment, +): Record { + return { + action: assessment.action, + questId: assessment.questId, + governed: assessment.governed, + policyId: assessment.policyId ?? null, + allowManualSeal: assessment.allowManualSeal ?? null, + code: assessment.code ?? null, + tracked: assessment.tracked ?? null, + complete: assessment.complete ?? null, + verdict: assessment.verdict ?? null, + requirementCount: assessment.requirementCount ?? null, + criterionCount: assessment.criterionCount ?? null, + coverageRatio: assessment.coverageRatio ?? null, + failingCriterionIds: assessment.failingCriterionIds, + linkedOnlyCriterionIds: assessment.linkedOnlyCriterionIds, + missingCriterionIds: assessment.missingCriterionIds, + }; +} diff --git a/test/unit/SettlementGateService.test.ts b/test/unit/SettlementGateService.test.ts new file mode 100644 index 0000000..56df696 --- /dev/null +++ b/test/unit/SettlementGateService.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from 'vitest'; +import type { QuestDetail } from '../../src/domain/models/dashboard.js'; +import { + assessSettlementGate, + formatSettlementGateFailure, + settlementGateFailureData, +} from '../../src/domain/services/SettlementGateService.js'; + +function makeQuestDetail(overrides?: Partial): QuestDetail { + return { + id: 'task:Q1', + quest: { + id: 'task:Q1', + title: 'Governed quest', + status: 'PLANNED', + hours: 1, + taskKind: 'delivery', + computedCompletion: { + tracked: true, + complete: true, + verdict: 'SATISFIED', + requirementCount: 1, + criterionCount: 1, + coverageRatio: 1, + satisfiedCount: 1, + failingCriterionIds: [], + linkedOnlyCriterionIds: [], + missingCriterionIds: [], + policyId: 'policy:TRACE', + }, + }, + reviews: [], + decisions: [], + stories: [], + requirements: [], + criteria: [], + evidence: [], + policies: [{ + id: 'policy:TRACE', + campaignId: 'campaign:TRACE', + coverageThreshold: 1, + requireAllCriteria: true, + requireEvidence: true, + allowManualSeal: false, + }], + documents: [], + comments: [], + timeline: [], + ...overrides, + }; +} + +describe('SettlementGateService', () => { + it('allows ungoverned work to settle', () => { + const assessment = assessSettlementGate(makeQuestDetail({ + policies: [], + }), 'seal'); + + expect(assessment.allowed).toBe(true); + expect(assessment.governed).toBe(false); + }); + + it('blocks governed work when computed completion is incomplete', () => { + const assessment = assessSettlementGate(makeQuestDetail({ + quest: { + ...makeQuestDetail().quest, + computedCompletion: { + tracked: true, + complete: false, + verdict: 'FAILED', + requirementCount: 1, + criterionCount: 1, + coverageRatio: 0, + satisfiedCount: 0, + failingCriterionIds: ['criterion:Q1'], + linkedOnlyCriterionIds: [], + missingCriterionIds: [], + policyId: 'policy:TRACE', + }, + }, + }), 'merge'); + + expect(assessment).toMatchObject({ + allowed: false, + governed: true, + action: 'merge', + policyId: 'policy:TRACE', + verdict: 'FAILED', + code: 'governed-work-failing-evidence', + failingCriterionIds: ['criterion:Q1'], + }); + expect(formatSettlementGateFailure(assessment)).toContain('blocks settlement'); + expect(settlementGateFailureData(assessment)).toMatchObject({ + action: 'merge', + policyId: 'policy:TRACE', + verdict: 'FAILED', + }); + }); + + it('allows governed work when the policy explicitly permits manual settlement', () => { + const assessment = assessSettlementGate(makeQuestDetail({ + policies: [{ + id: 'policy:TRACE', + campaignId: 'campaign:TRACE', + coverageThreshold: 1, + requireAllCriteria: true, + requireEvidence: true, + allowManualSeal: true, + }], + quest: { + ...makeQuestDetail().quest, + computedCompletion: { + tracked: true, + complete: false, + verdict: 'MISSING', + requirementCount: 1, + criterionCount: 1, + coverageRatio: 0, + satisfiedCount: 0, + failingCriterionIds: [], + linkedOnlyCriterionIds: [], + missingCriterionIds: ['criterion:Q1'], + policyId: 'policy:TRACE', + }, + }, + }), 'seal'); + + expect(assessment.allowed).toBe(true); + expect(assessment.allowManualSeal).toBe(true); + }); +}); diff --git a/test/unit/SignedSettlementCommands.test.ts b/test/unit/SignedSettlementCommands.test.ts index 2103d02..b63838c 100644 --- a/test/unit/SignedSettlementCommands.test.ts +++ b/test/unit/SignedSettlementCommands.test.ts @@ -1,6 +1,7 @@ import { Command } from 'commander'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { CliContext, JsonEnvelope } from '../../src/cli/context.js'; +import type { EntityDetail } from '../../src/domain/models/dashboard.js'; import { allowUnsignedScrollsForSettlement, registerArtifactCommands, @@ -21,6 +22,7 @@ const mocks = vi.hoisted(() => ({ isMerged: vi.fn(), merge: vi.fn(), getHeadCommit: vi.fn(), + fetchEntityDetail: vi.fn(), })); vi.mock('../../src/domain/services/GuildSealService.js', () => ({ @@ -93,6 +95,75 @@ vi.mock('../../src/infrastructure/adapters/GitWorkspaceAdapter.js', () => ({ }, })); +vi.mock('../../src/infrastructure/GraphContext.js', () => ({ + createGraphContext: () => ({ + fetchEntityDetail(id: string): Promise { + return mocks.fetchEntityDetail(id); + }, + }), +})); + +function makeQuestDetail( + overrides?: Partial>, +): EntityDetail { + return { + id: 'task:Q1', + type: 'task', + props: {}, + outgoing: [], + incoming: [], + questDetail: { + id: 'task:Q1', + quest: { + id: 'task:Q1', + title: 'Governed quest', + status: 'PLANNED', + hours: 1, + taskKind: 'delivery', + computedCompletion: { + tracked: true, + complete: true, + verdict: 'SATISFIED', + requirementCount: 1, + criterionCount: 1, + coverageRatio: 1, + satisfiedCount: 1, + failingCriterionIds: [], + linkedOnlyCriterionIds: [], + missingCriterionIds: [], + policyId: 'policy:TRACE', + }, + }, + reviews: [], + decisions: [], + stories: [], + requirements: [], + criteria: [], + evidence: [], + policies: [{ + id: 'policy:TRACE', + campaignId: 'campaign:TRACE', + coverageThreshold: 1, + requireAllCriteria: true, + requireEvidence: true, + allowManualSeal: false, + }], + documents: [], + comments: [], + timeline: [], + ...overrides, + }, + }; +} + +function defaultQuestNode(): NonNullable['quest'] { + const detail = makeQuestDetail().questDetail; + if (!detail) { + throw new Error('Expected default quest detail fixture'); + } + return detail.quest; +} + function createJsonCtx(overrides: Partial = {}): CliContext { return { agentId: 'agent.test', @@ -155,6 +226,7 @@ describe('signed settlement enforcement', () => { mocks.isMerged.mockResolvedValue(false); mocks.merge.mockResolvedValue('abcdef1234567890'); mocks.getHeadCommit.mockResolvedValue('abcdef1234567890'); + mocks.fetchEntityDetail.mockResolvedValue(makeQuestDetail()); }); afterEach(() => { @@ -252,6 +324,52 @@ describe('signed settlement enforcement', () => { ])); }); + it('seal fails for governed work when computed completion is incomplete', async () => { + mocks.fetchEntityDetail.mockResolvedValue(makeQuestDetail({ + quest: { + ...defaultQuestNode(), + computedCompletion: { + tracked: true, + complete: false, + verdict: 'MISSING', + requirementCount: 1, + criterionCount: 1, + coverageRatio: 0, + satisfiedCount: 0, + failingCriterionIds: [], + linkedOnlyCriterionIds: [], + missingCriterionIds: ['criterion:Q1'], + policyId: 'policy:TRACE', + }, + }, + })); + + const program = new Command(); + registerArtifactCommands(program, createJsonCtx()); + + await program.parseAsync( + ['seal', 'task:Q1', '--artifact', 'artifact-sha', '--rationale', 'attempt governed seal'], + { from: 'user' }, + ); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(mocks.sign).not.toHaveBeenCalled(); + + const output = JSON.parse(String(logSpy.mock.calls.at(-1)?.[0])); + expect(output).toMatchObject({ + success: false, + error: expect.stringContaining('policy policy:TRACE blocks settlement'), + data: { + action: 'seal', + questId: 'task:Q1', + governed: true, + policyId: 'policy:TRACE', + verdict: 'MISSING', + missingCriterionIds: ['criterion:Q1'], + }, + }); + }); + it('merge fails before git settlement when auto-seal needs a key', async () => { mocks.hasPrivateKey.mockReturnValue(false); @@ -281,4 +399,54 @@ describe('signed settlement enforcement', () => { }, }); }); + + it('merge fails before git settlement when governed completion is incomplete', async () => { + mocks.fetchEntityDetail.mockResolvedValue(makeQuestDetail({ + quest: { + ...defaultQuestNode(), + computedCompletion: { + tracked: true, + complete: false, + verdict: 'LINKED', + requirementCount: 1, + criterionCount: 1, + coverageRatio: 0, + satisfiedCount: 0, + failingCriterionIds: [], + linkedOnlyCriterionIds: ['criterion:Q1'], + missingCriterionIds: [], + policyId: 'policy:TRACE', + }, + }, + })); + + const program = new Command(); + registerSubmissionCommands(program, createJsonCtx()); + + await program.parseAsync( + ['merge', 'submission:S1', '--rationale', 'attempt governed merge'], + { from: 'user' }, + ); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(mocks.isMerged).not.toHaveBeenCalled(); + expect(mocks.merge).not.toHaveBeenCalled(); + expect(mocks.decide).not.toHaveBeenCalled(); + expect(mocks.sign).not.toHaveBeenCalled(); + + const output = JSON.parse(String(logSpy.mock.calls.at(-1)?.[0])); + expect(output).toMatchObject({ + success: false, + error: expect.stringContaining('policy policy:TRACE blocks settlement'), + data: { + submissionId: 'submission:S1', + action: 'merge', + questId: 'task:Q1', + governed: true, + policyId: 'policy:TRACE', + verdict: 'LINKED', + linkedOnlyCriterionIds: ['criterion:Q1'], + }, + }); + }); }); From be6fc4fbeaeb1e1d03e2f146ead223a8270a8676 Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 12 Mar 2026 12:16:30 -0700 Subject: [PATCH 03/22] Document the agent-native CLI protocol --- README.md | 3 +- docs/CLI-plan.md | 5 + docs/PLAN.md | 274 ++++++++++++++--------------- docs/TUI-plan.md | 5 + docs/canonical/AGENT_CHARTER.md | 1 + docs/canonical/AGENT_PROTOCOL.md | 292 +++++++++++++++++++++++++++++++ docs/canonical/ARCHITECTURE.md | 19 +- 7 files changed, 454 insertions(+), 145 deletions(-) create mode 100644 docs/canonical/AGENT_PROTOCOL.md diff --git a/README.md b/README.md index 29f14f4..5bc5320 100644 --- a/README.md +++ b/README.md @@ -383,7 +383,7 @@ xyph-dashboard.ts # Interactive TUI entry point | 9 | FORGE — emit + apply phases | ⬜ PLANNED | | 10 | CLI TOOLING — identity, packaging, time-travel, ergonomics | 🔧 IN PROGRESS | | 11 | TRACEABILITY — stories, requirements, acceptance criteria, evidence | ⬜ PLANNED | -| 12 | AGENT PROTOCOL — structured agent interface (briefing, next, context, handoff) | ⬜ PLANNED | +| 12 | AGENT PROTOCOL — agent-native CLI and policy-bounded action kernel | ⬜ PLANNED | | — | ECOSYSTEM — MCP server, Web UI, IDE integration | ⬜ PLANNED | Milestone descriptions and inter-milestone dependencies are modeled in the WARP graph. Query via: `npx tsx xyph-actuator.ts status --view deps` @@ -410,6 +410,7 @@ The `docs/canonical/` directory contains the foundational specifications: **Architecture & Pipeline** - [ARCHITECTURE.md](docs/canonical/ARCHITECTURE.md) — Module structure and dependency rules +- [AGENT_PROTOCOL.md](docs/canonical/AGENT_PROTOCOL.md) — Agent-native CLI and action-kernel contract - [ORCHESTRATION_SPEC.md](docs/canonical/ORCHESTRATION_SPEC.md) — Planning pipeline state machine - [SCHEDULING_AND_DAG.md](docs/canonical/SCHEDULING_AND_DAG.md) — DAG scheduling primitives (critical path, anti-chains, lanes) - [ROADMAP_PROTOCOL.md](docs/canonical/ROADMAP_PROTOCOL.md) — Task and milestone lifecycle states diff --git a/docs/CLI-plan.md b/docs/CLI-plan.md index ce5a760..faa11ab 100644 --- a/docs/CLI-plan.md +++ b/docs/CLI-plan.md @@ -1,5 +1,10 @@ # XYPH CLI & Agent Interface — Enhancement Plan +> **Note:** The canonical contract for the agent-native CLI now lives in +> [`docs/canonical/AGENT_PROTOCOL.md`](docs/canonical/AGENT_PROTOCOL.md). +> This file remains a broader enhancement/backlog plan and may use older +> command sketches or names. + ## Context The CLI (`xyph-actuator.ts`) is the primary interface for both humans and agents. Bijou v0.6.0 introduced interactive primitives (`wizard()`, `filter()`, `textarea()`) that can transform multi-flag commands into guided flows. Meanwhile, the agent interface is underserved — agents need structured I/O, session lifecycle commands, and batch operations to work efficiently. diff --git a/docs/PLAN.md b/docs/PLAN.md index f78f097..e80571a 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -1,149 +1,137 @@ -# XYPH Bring-Up Plan: Make the Existing Alpha Honest, Operable, and Then Spec-Complete +# XYPH Steering Plan: Honest Core -> Agent-Native -> Human-Friendly ## Summary -XYPH is already a real alpha product, but it is not yet “up to spec” in the sense its canonical docs promise. - -Current state, based on the repo and its own `--json` status output: -- Build, lint, and tests are green: 56 test files, 732 tests passing. -- Core runtime works: Git-backed graph, CLI, TUI, dependency analysis, submission lifecycle, merge/seal flow, and a traceability model in code. -- Self-tracked graph says 69 quests are `DONE`, 24 are `PLANNED`, and 129 are still `BACKLOG`. -- 7 of 13 campaigns are marked `DONE`, but at least some campaign statuses are stale and not credible relative to quest reality. -- 59 scrolls exist, but only 1 is actually cryptographically signed. -- The self-graph has 0 stories, 0 requirements, 0 criteria, 0 evidence, and 0 suggestions, so the traceability system exists in code but is not yet dogfooded. -- `audit-sovereignty --json` reports 51 violations, which means the repo is not obeying its own intent-lineage story consistently. -- The planning-compiler spec is only partially implemented. The missing weight is in ORACLE/FORGE: classification, policy engine, merge planning, emit/apply, and end-to-end compiler artifacts. - -My completeness estimate: -- Product alpha completeness: 60-65% -- Canonical spec completeness: 30-35% -- Self-dogfooding / “truthfulness of its own graph”: 20-25% - -## Key Product Decisions - -- Optimize for product integrity first, not literal spec completion first. The repo is already useful as a graph-native coordination tool; the fastest path to credibility is making the current system truthful and self-hosting. -- Freeze the status semantics now: `BACKLOG` is unauthorized triage; `PLANNED`, `IN_PROGRESS`, `BLOCKED`, and `DONE` are authorized work. Do not reintroduce a second inbox state. Fix the audits and docs to match this. -- Stop treating stored campaign status as authoritative. Compute campaign status from member quests and show only derived values in CLI/TUI. -- Treat `--json` CLI as the automation API for v1. Do not build a local REST/socket API before the CLI contract is stable. -- Keep direct user-driven CLI mutations for manual workflows. Build the compiler pipeline as a second path for ingest/planning, not as an immediate rewrite of every manual command. -- Defer ecosystem and vanity features until core integrity is fixed. That means no Web UI, IDE plugin, MCP server, graph explorer, or heavy TUI redesign work until the graph model, traceability, and compiler path are trustworthy. - -## Milestone Schedule - -### Milestone 0 — Truth Repair and Dogfood Hygiene -Target: 1 week - -- Remove read-path write behavior and warnings from normal inspection flows. `status --json` should not emit checkpoint failures during routine reads. -- Make sovereignty rules consistent with actual workflow. Audit only authorized work, not triage-only backlog items. -- Backfill or relabel the current self-tracked graph so the repo no longer reports obvious constitutional violations. -- Compute campaign status from quests and ignore stale stored campaign status for display and reporting. -- Surface signature state clearly and require agent key setup for all new seal/merge operations in non-dev workflows. - -Exit criteria: -- `audit-sovereignty --json` is green for all authorized work. -- `status --json` runs without checkpoint warnings. -- Campaign status in CLI/TUI matches quest reality. -- All new scrolls are signed by default. - -### Milestone 1 — CLI Foundation v1 -Target: 2 weeks - -- Ship a real `xyph` binary entrypoint and normalize command ergonomics. -- Implement identity resolution with explicit precedence: `--as`, env, repo config, user config, fallback default. -- Add `whoami`, `login`, `logout`, and a full `show/context` inspection path so agents and humans can bootstrap work without reading raw graph dumps. -- Make JSON output a stable v1 contract across all user-facing commands. -- Fix workflow gaps in promote/reject/history/status so provenance is complete and scriptable. -- Harden merge/workspace behavior so graph validation and Git settlement fail more predictably. - -Exit criteria: -- A human or agent can bootstrap, inspect, claim, submit, review, and settle work using only the CLI and `--json`. -- JSON output is stable enough to support CI and agent automation. - -### Milestone 2 — Traceability v1 That Is Actually Used -Target: 2 weeks - -- Finish the missing traceability pieces: policy nodes, governed campaigns, computed coverage queries, and campaign-level definition-of-done checks. -- Dogfood traceability on this repo itself. Start with `CLITOOL` and `FORGE`, not the whole graph. -- Require stories/requirements/criteria/evidence for governed campaigns only. Do not hard-gate the whole repo at once. -- Wire `scan`/`analyze` into CI for governed campaigns so evidence is generated and reviewed continuously. -- Expose coverage and unmet criteria in CLI/TUI status. - -Exit criteria: -- Self-graph contains non-zero stories, requirements, criteria, and evidence. -- At least one campaign is governed by real traceability policy. -- Seal/merge is blocked when governed work lacks required evidence. - -### Milestone 3 — ORACLE + FORGE Compiler Path -Target: 3 weeks - -- Implement the missing compiler phases in the planning path: classify, validate, merge planning, schedule, review, emit, apply. -- Reuse the existing signed patch-ops validator as the compiler IR validation layer instead of inventing a second artifact system. -- Emit typed artifacts and audit records for each phase, but keep manual command flows intact. -- Restrict APPLY semantics to compiler-driven planning operations; manual graph edits remain separate and explicit. -- Add one real end-to-end compiler flow on the self-repo: ingest structured planning input, emit patch artifact, validate, apply, and audit. - -Exit criteria: -- One supported compiler path runs end to end from ingest to apply. -- Artifacts and audit nodes are durable and queryable. -- The canonical FORGE story is no longer mostly doc-only. - -### Milestone 4 — Agent Protocol and TUI Operationalization -Target: 2 weeks - -- Build the useful agent-facing commands first: `briefing`, `context`, `submissions`, `review`, `submit`, and `handoff`. -- Add the minimum TUI surfaces needed for real ops: suggestions, graveyard, alerts, traceability coverage, and signature/health indicators. -- Do not prioritize overview redesign chains, chord-mode polish, or visual experiments ahead of workflow closure. -- Make self-hosting explicit: the repo must be able to plan and drive itself via XYPH without outside spreadsheets/docs. - -Exit criteria: -- An agent can enter the repo cold, ask the CLI for context, find work, submit, review, and hand off. -- TUI surfaces the operational state that matters, not just pretty summaries. - -### Milestone 5 — Ecosystem and Expansion -Target: later, after v1 core is stable - -- MCP server -- Web UI -- IDE integrations -- Time-travel and provenance explorer features (`diff`, `seek`, `slice`, graph explorer) -- Multi-user proof and large-graph scaling work - -These are valuable, but they should not block v1 credibility. - -## Missing Features to Call Out Explicitly - -- Spec/runtime mismatch around sovereignty and backlog semantics -- Derived campaign status and truthful milestone reporting -- Stable CLI identity model and packaging -- Stable JSON automation contract -- Real self-hosted traceability data -- Governed definition-of-done enforcement -- Compiler phases ORACLE/FORGE -- Durable audit artifacts for compiler path -- Agent protocol beyond a few early commands -- Default cryptographic signing discipline for scrolls - -## Test and Acceptance Plan - -- Keep build, lint, and full test suite green on every milestone. -- Add CI assertions that use the product’s own `--json` output: - - sovereignty audit passes for authorized work - - campaign status is derived and consistent - - governed campaigns have non-zero traceability coverage - - new scrolls are signed -- Add end-to-end tests for: - - triage to promoted work with correct intent lineage - - submit/revise/review/merge with full provenance - - governed traceability gate on seal/merge - - compiler ingest → emit → apply → audit flow -- Add golden tests for JSON envelopes so agent integrations do not drift. -- Add a self-hosting acceptance check: the repo can represent and advance its own roadmap without external bookkeeping. +XYPH should be steered through three checkpoints: + +1. **Honest Core** — make the runtime, graph, and canonical docs agree. +2. **Agent-Native** — make XYPH the policy-bounded operating interface for agents. +3. **Human-Friendly** — make the human operator surface reuse the same kernel. + +This replaces the earlier bias toward “truth first, then TUI.” The TUI is +important, but it should be layered on top of a real agent-native protocol and +action kernel, not built ahead of it. + +## Checkpoint 1 — Honest Core + +Focus: + +- keep lifecycle, readiness, traceability, and settlement behavior truthful +- finish self-dogfooding on governed campaigns such as `CLITOOL` and `TRACE` +- backfill stale self-roadmap state so shipped capabilities are reflected in the + graph +- treat `show` and the quest-detail projection as the canonical issue-page + substrate + +Completion bar: + +- `status --json`, `show --json`, and `audit-sovereignty --json` are truthful + and stable +- governed quests cannot pass readiness or settlement dishonestly +- the repo's own graph contains real stories, requirements, criteria, and + evidence + +## Checkpoint 2 — Agent-Native + +Focus: + +- build the shared agent services: + - `AgentBriefingService` + - `AgentRecommender` + - `AgentActionValidator` + - `AgentActionService` + - `AgentContextService` or equivalent +- stabilize the agent-facing JSON commands: + - `briefing` + - `next` + - `context` + - `submissions` + - `act` + - `handoff` +- make `act` a policy-bounded action kernel over routine operations + +Checkpoint-2 action kinds: + +- `claim` +- `shape` +- `packet` +- `ready` +- `comment` +- `submit` +- `review` +- `handoff` +- `seal` +- `merge` + +Still human-only in checkpoint 2: + +- `intent` +- `promote` +- `reject` +- `reopen` +- `depend` +- campaign mutation +- policy mutation +- any constitutionally sensitive scope or sovereignty change + +Completion bar: + +- a cold-start agent can orient, choose work, act through XYPH, submit or + review, settle governed work when policy passes, and leave a graph-native + handoff +- every allowed agent mutation flows through the same validators and gates as + the human CLI + +## Checkpoint 3 — Human-Friendly + +Focus: + +- build an ops-grade human surface on top of the same read/write services +- prioritize quest detail, triage, submissions, graveyard, alerts, + traceability coverage, and graph/trust health +- keep the TUI as an operator console, not a separate workflow model + +Defer out of this checkpoint: + +- web UI +- polish-first redesign work +- graph explorer vanity features +- large TUI chains that do not improve operator throughput + +Completion bar: + +- a human can supervise agents, inspect quests like issue pages, triage work, + review submissions, and override when allowed, entirely through XYPH + +## Product Decisions + +- **One source of truth**: CLI, agent protocol, and TUI all consume the same + graph-backed read models. +- **One action kernel**: `act` and future human surfaces reuse the same domain + validators and mutation services. +- **JSON first**: CLI `--json` is the primary automation surface; MCP is later. +- **Graph-native collaboration**: quest-linked notes, specs, comments, and + handoffs live in the graph, not in repo files as the source of truth. +- **Compiler track deferred**: ORACLE/FORGE remains important, but it is not a + checkpoint blocker before the agent-native kernel is real. + +## Acceptance and Verification + +- Keep build, lint, and local test suite green through every checkpoint. +- Add golden JSON tests for the agent-facing commands. +- Add end-to-end agent session tests: + - `briefing -> next -> context -> act -> submit/review/merge/seal -> handoff` +- Add negative tests for: + - human-only actions + - readiness, sovereignty, and settlement rejections + - governed incomplete work +- Add self-hosting checks proving XYPH can coordinate and advance its own + roadmap through the agent-native protocol. ## Assumptions and Defaults -- Schedule assumes one strong maintainer with AI assistance. Two engineers can roughly halve calendar time. -- The zero-hour estimates in the graph are not trustworthy planning inputs; milestone scheduling here is based on implementation risk, not current quest hour fields. -- `BACKLOG` remains the triage bucket. I would not reintroduce `INBOX`. -- REST/socket APIs are deferred; CLI `--json` is the supported automation surface for v1. -- Manual CLI mutations remain supported. The compiler path is additive, not a rewrite of the whole product. -- If forced to cut scope, I would cut ecosystem/UI polish first, not traceability or compiler bring-up. +- Checkpoint order is fixed: Honest Core -> Agent-Native -> Human-Friendly. +- The agent-native checkpoint is intentionally bold: it includes an action + kernel, not just read-only agent commands. +- Agent authority is policy-bounded, not sovereign. +- Human-friendly means ops-grade TUI first, not web-first. diff --git a/docs/TUI-plan.md b/docs/TUI-plan.md index 654fc34..8741210 100644 --- a/docs/TUI-plan.md +++ b/docs/TUI-plan.md @@ -1,5 +1,10 @@ # XYPH Interactive TUI — Full Dashboard Plan +> **Note:** The canonical contract for the agent-native CLI and action kernel +> now lives in [`docs/canonical/AGENT_PROTOCOL.md`](docs/canonical/AGENT_PROTOCOL.md). +> The agent-command sections in this file are design context for the TUI, not +> the authoritative protocol spec. + ## Context The XYPH TUI dashboard is currently read-only with 4 views (roadmap, lineage, all, inbox). The domain has rich data (submissions, reviews, decisions, sovereignty audits) and write operations (claim, promote, reject, review) that aren't surfaced. The goal is to make the TUI the **primary interface** — fully interactive, with all key data and operations accessible. diff --git a/docs/canonical/AGENT_CHARTER.md b/docs/canonical/AGENT_CHARTER.md index fae42ad..ea5a6d2 100644 --- a/docs/canonical/AGENT_CHARTER.md +++ b/docs/canonical/AGENT_CHARTER.md @@ -1,6 +1,7 @@ # AGENT CHARTER **Version:** 1.0.0 **Status:** DRAFT — This describes a proposed 6-agent role architecture that has not been implemented. The current system uses a single generic writer identity per participant. Tracked by `task:doc-agent-charter`. +**Related:** `AGENT_PROTOCOL.md` is the canonical spec for the actual agent-native CLI and action kernel. This charter is about role decomposition, not the concrete command surface. **Enforcement:** HARD BOUNDARY VIOLATION = IMMEDIATE REJECT ## Agent Roster & Scopes diff --git a/docs/canonical/AGENT_PROTOCOL.md b/docs/canonical/AGENT_PROTOCOL.md new file mode 100644 index 0000000..983df3f --- /dev/null +++ b/docs/canonical/AGENT_PROTOCOL.md @@ -0,0 +1,292 @@ +# AGENT PROTOCOL +**Version:** 0.1.0 +**Status:** DRAFT +**Depends on:** CONSTITUTION.md, ROADMAP_PROTOCOL.md, TRACEABILITY.md, ARCHITECTURE.md + +## 1. Purpose + +XYPH's agent protocol defines the **agent-native CLI** and the **action kernel** +that sits behind it. + +The goal is not "friendlier scripting." The goal is that an agent can: + +1. enter the repo cold, +2. ask XYPH what is true, +3. ask XYPH what it is allowed to do, +4. execute allowed routine work through XYPH itself, +5. leave durable graph-native handoff state behind. + +The agent protocol is therefore a **policy-bounded operating interface**, not a +second workflow model and not an informal wrapper around raw commands. + +## 2. Core Rules + +1. **One source of truth** + All agent protocol reads come from the same graph-backed read models used by + human surfaces. No agent-only shadow state. + +2. **One action kernel** + Agent writes must reuse the same validators and domain services that govern + normal CLI commands. `act` is a strict door, not a shortcut. + +3. **JSON first** + The primary agent API is CLI `--json`. Text and markdown are debug and + context-injection modes, not the canonical wire shape. + +4. **Policy-bounded authority** + Agents may perform routine operations when XYPH gates pass. Sovereignty, + scope control, and constitutionally sensitive changes remain human-bound. + +5. **Graph-native collaboration** + Handoffs, notes, comments, and quest-linked discussion live in the WARP + graph as nodes with queryable metadata and attached content blobs. + +## 3. Command Set + +The agent-native CLI surface is: + +- `xyph briefing` +- `xyph next` +- `xyph context ` +- `xyph submissions` +- `xyph act ` +- `xyph handoff` + +Existing domain commands such as `submit`, `review`, `seal`, and `merge` remain +the underlying mutation primitives. `act` wraps them with a common validation +and response contract. + +### 3.1 `show` vs `context` + +- `show ` remains general entity inspection. +- `context ` is the work packet for agents. + +`context` must be deeper and more action-oriented than `show`. For `task:*`, it +includes: + +- quest detail and timeline +- campaign and intent lineage +- upstream and downstream dependency context +- active or recent submissions, reviews, and decisions +- traceability packet, computed completion, and applied policy state +- recent graph-native docs and comments +- recommended next actions for that specific target + +## 4. JSON Contracts + +### 4.1 `briefing --json` + +`briefing` is the start-of-session orientation document. At minimum it returns: + +- `identity` +- `assignments` +- `reviewQueue` +- `frontier` +- `alerts` +- `graphMeta` + +Each frontier or review entry should already contain an executable next step or +an action candidate reference. + +### 4.2 `next --json` + +`next` returns structured action candidates, not prose-only recommendations. + +Each candidate must include at least: + +- `kind` +- `targetId` +- `args` +- `reason` +- `confidence` +- `requiresHumanApproval` +- `dryRunSummary` +- `blockedBy` + +The first candidate is the default recommendation. Remaining candidates are +ordered alternatives. + +### 4.3 `submissions --json` + +`submissions` is the agent-facing queue view. It should group at least: + +- `owned` submissions +- `reviewable` submissions +- `stale` or attention-needed submissions + +Each entry should expose enough normalized data for `act review ...` or +follow-on `context` calls without forcing extra graph archaeology. + +### 4.4 `act --json` + +`act` is a generic validated execution wrapper: + +```bash +xyph act [action-specific options] [--dry-run] --json +``` + +The `--json` result must include: + +- `kind` +- `targetId` +- `allowed` +- `dryRun` +- `requiresHumanApproval` +- `validation` +- `normalizedArgs` +- `underlyingCommand` +- `sideEffects` +- `result` +- `patch` when a mutation succeeds + +`validation` must contain machine-readable failure reasons when the action is +rejected. Rejections must happen **before** any graph or workspace mutation. + +### 4.5 `handoff --json` + +`handoff` records session closeout as durable graph state. The JSON result must +include: + +- `noteId` +- `authoredBy` +- `authoredAt` +- `relatedIds` +- `patch` + +The output may also include summarization stats such as affected tasks, +submissions, or recent patches, but those are secondary to the durable note. + +## 5. Action Kernel + +Checkpoint-2 action kinds are: + +- `claim` +- `shape` +- `packet` +- `ready` +- `comment` +- `submit` +- `review` +- `handoff` +- `seal` +- `merge` + +These are the only routine agent actions that should be executable through +`act` in the checkpoint-2 kernel. + +### 5.1 Human-only actions + +The following remain human-only in checkpoint 2: + +- `intent` +- `promote` +- `reject` +- `reopen` +- `depend` +- campaign mutation +- policy mutation +- any action that changes scope, critical path, or sovereignty state in a way + that the constitution reserves for humans + +If an agent requests one of these through `act`, XYPH must reject it with an +explicit machine-readable reason such as: + +- `human-only-action` +- `requires-human-approval` +- `sovereignty-boundary` + +### 5.2 Validation sources + +The action kernel must reuse existing domain gates rather than re-inventing +them: + +- readiness checks from `ReadinessService` +- submission workflow validation from `SubmissionService` +- settlement checks from `SettlementGateService` +- sovereignty enforcement from `SovereigntyService` +- quest/campaign read models from `GraphContext` + +### 5.3 Dry-run semantics + +Every `act` kind supports `--dry-run`. + +Dry-run must: + +- run the same validation stack as real execution +- resolve normalized arguments +- report expected side effects +- perform no graph or workspace mutation + +## 6. Handoff Storage Model + +`handoff` does not introduce a new node family in checkpoint 2. + +It writes a `note:*` node with: + +- `type = note` +- `note_kind = handoff` +- `authored_by` +- `authored_at` +- optional session metadata such as `session_started_at` and `session_ended_at` + +Relationships are represented with edges: + +- `documents -> task:*` +- `documents -> submission:*` +- `documents -> campaign:*` when the handoff is campaign-scoped + +The long-form session summary lives in attached content via WARP content blobs. + +## 7. Architecture + +The agent-native CLI should be implemented as a thin driving adapter over shared +domain services: + +- `AgentBriefingService` +- `AgentRecommender` +- `AgentActionValidator` +- `AgentActionService` +- `AgentContextService` or an equivalent context-specialized read service + +The high-level flow is: + +```text +CLI command + -> GraphContext-backed read model or agent service + -> shared domain validator / action service + -> existing mutation adapters and domain services + -> WARP graph / git workspace +``` + +The TUI and future MCP layer must reuse these services rather than implementing +their own mutation logic. + +## 8. Relationship to Other Agent Docs + +`AGENT_CHARTER.md` describes a speculative multi-agent role architecture. +It does **not** define the concrete agent-native CLI. + +This document is the canonical spec for: + +- the agent-facing CLI contract +- the action-kernel authority boundary +- the required JSON envelopes +- handoff persistence + +If the charter and this protocol diverge, this protocol governs implementation +of the CLI and action kernel. + +## 9. Acceptance Bar + +The agent-native checkpoint is complete when a cold-start agent can: + +1. run `briefing` +2. run `next` +3. inspect a target with `context` +4. execute allowed routine work through `act` +5. submit or review through the same kernel +6. settle governed work only when XYPH gates pass +7. leave a graph-native `handoff` + +At that point, XYPH is no longer just "usable by agents." It is the agent's +operating interface. diff --git a/docs/canonical/ARCHITECTURE.md b/docs/canonical/ARCHITECTURE.md index c10d7e6..c017d57 100644 --- a/docs/canonical/ARCHITECTURE.md +++ b/docs/canonical/ARCHITECTURE.md @@ -21,7 +21,7 @@ ### Layers - **`src/domain/entities/`** — Core business objects: `Quest`, `Intent`, `Submission`, `ApprovalGate`, `Orchestration`. -- **`src/domain/services/`** — Domain logic: `CoordinatorService`, `SubmissionService`, `IntakeService`, `DepAnalysis`, `GuildSealService`, `SovereigntyService`, `IngestService`, `NormalizeService`, `RebalanceService`. +- **`src/domain/services/`** — Domain logic: `CoordinatorService`, `SubmissionService`, `IntakeService`, `DepAnalysis`, `GuildSealService`, `SovereigntyService`, `IngestService`, `NormalizeService`, `RebalanceService`, and the agent-kernel services defined by `AGENT_PROTOCOL.md`. - **`src/domain/models/`** — View models for the TUI dashboard (`dashboard.ts`). - **`src/ports/`** — Boundary interfaces: `GraphPort`, `RoadmapPort`, `IntakePort`, `SubmissionPort`, `WorkspacePort`. - **`src/infrastructure/adapters/`** — Concrete implementations backed by git-warp and git: `WarpGraphAdapter`, `WarpIntakeAdapter`, `WarpSubmissionAdapter`, `WarpRoadmapAdapter`, `GitWorkspaceAdapter`. @@ -71,6 +71,20 @@ submit → patchset → review → revise → approve → merge/close auto-seal quest DONE ``` +### Agent-Native Lifecycle +``` +briefing → next → context → act → handoff + │ + └→ submit/review/seal/merge (when the same gates pass) +``` + +- `show` remains general entity inspection. +- `context` is the action-oriented work packet. +- `act` wraps routine mutations but must still reuse readiness, submission, + sovereignty, and settlement gates. +- Future TUI and MCP surfaces should call the same agent-kernel services rather + than inventing parallel mutation paths. + ## Key Services | Service | Responsibility | @@ -81,6 +95,9 @@ submit → patchset → review → revise → approve → merge/close | `DepAnalysis` | Frontier detection, critical path DP over dependency DAG | | `GuildSealService` | Ed25519 signing for Project Scrolls | | `SovereigntyService` | Genealogy of Intent audit (Constitution Art. IV) | +| `AgentBriefingService` | Session-start orientation document for agents | +| `AgentRecommender` | Ranked next-action candidates for agent work | +| `AgentActionValidator` / `AgentActionService` | Policy-bounded action kernel over routine CLI mutations | ## Graph Node Types From e4ed6743a6b8c799c840e6247cae7ec6709b7727 Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 12 Mar 2026 12:33:27 -0700 Subject: [PATCH 04/22] Add v1 agent action kernel --- docs/canonical/AGENT_PROTOCOL.md | 13 + src/cli/commands/agent.ts | 147 ++++ src/domain/services/AgentActionService.ts | 874 ++++++++++++++++++++++ test/unit/AgentActionService.test.ts | 211 ++++++ test/unit/AgentCommands.test.ts | 214 ++++++ xyph-actuator.ts | 2 + 6 files changed, 1461 insertions(+) create mode 100644 src/cli/commands/agent.ts create mode 100644 src/domain/services/AgentActionService.ts create mode 100644 test/unit/AgentActionService.test.ts create mode 100644 test/unit/AgentCommands.test.ts diff --git a/docs/canonical/AGENT_PROTOCOL.md b/docs/canonical/AGENT_PROTOCOL.md index 983df3f..0c8d593 100644 --- a/docs/canonical/AGENT_PROTOCOL.md +++ b/docs/canonical/AGENT_PROTOCOL.md @@ -56,6 +56,11 @@ Existing domain commands such as `submit`, `review`, `seal`, and `merge` remain the underlying mutation primitives. `act` wraps them with a common validation and response contract. +Current runtime tranche: + +- shipped now: `claim`, `shape`, `packet`, `ready`, `comment` +- planned later in checkpoint 2: `submit`, `review`, `handoff`, `seal`, `merge` + ### 3.1 `show` vs `context` - `show ` remains general entity inspection. @@ -174,6 +179,14 @@ Checkpoint-2 action kinds are: These are the only routine agent actions that should be executable through `act` in the checkpoint-2 kernel. +The current runtime implementation ships the first tranche only: + +- `claim` +- `shape` +- `packet` +- `ready` +- `comment` + ### 5.1 Human-only actions The following remain human-only in checkpoint 2: diff --git a/src/cli/commands/agent.ts b/src/cli/commands/agent.ts new file mode 100644 index 0000000..4c8e42e --- /dev/null +++ b/src/cli/commands/agent.ts @@ -0,0 +1,147 @@ +import type { Command } from 'commander'; +import type { CliContext } from '../context.js'; +import { createErrorHandler } from '../errorHandler.js'; +import { VALID_TASK_KINDS } from '../../domain/entities/Quest.js'; +import { + VALID_REQUIREMENT_KINDS, + VALID_REQUIREMENT_PRIORITIES, +} from '../../domain/entities/Requirement.js'; +import { WarpRoadmapAdapter } from '../../infrastructure/adapters/WarpRoadmapAdapter.js'; +import { + AgentActionService, + type AgentActionOutcome, +} from '../../domain/services/AgentActionService.js'; + +interface ActOptions { + dryRun?: boolean; + description?: string; + kind?: string; + story?: string; + storyTitle?: string; + persona?: string; + goal?: string; + benefit?: string; + requirement?: string; + requirementDescription?: string; + requirementKind?: string; + priority?: string; + criterion?: string; + criterionDescription?: string; + verifiable?: boolean; + message?: string; + replyTo?: string; + commentId?: string; +} + +function buildActionArgs(opts: ActOptions): Record { + const args: Record = {}; + if (opts.description !== undefined) args['description'] = opts.description.trim(); + if (opts.kind !== undefined) args['taskKind'] = opts.kind; + if (opts.story !== undefined) args['storyId'] = opts.story; + if (opts.storyTitle !== undefined) args['storyTitle'] = opts.storyTitle.trim(); + if (opts.persona !== undefined) args['persona'] = opts.persona.trim(); + if (opts.goal !== undefined) args['goal'] = opts.goal.trim(); + if (opts.benefit !== undefined) args['benefit'] = opts.benefit.trim(); + if (opts.requirement !== undefined) args['requirementId'] = opts.requirement; + if (opts.requirementDescription !== undefined) { + args['requirementDescription'] = opts.requirementDescription.trim(); + } + if (opts.requirementKind !== undefined) args['requirementKind'] = opts.requirementKind; + if (opts.priority !== undefined) args['priority'] = opts.priority; + if (opts.criterion !== undefined) args['criterionId'] = opts.criterion; + if (opts.criterionDescription !== undefined) { + args['criterionDescription'] = opts.criterionDescription.trim(); + } + if (opts.verifiable === false) args['verifiable'] = false; + if (opts.message !== undefined) args['message'] = opts.message.trim(); + if (opts.replyTo !== undefined) args['replyTo'] = opts.replyTo; + if (opts.commentId !== undefined) args['commentId'] = opts.commentId; + return args; +} + +function renderHumanOutcome( + ctx: CliContext, + outcome: AgentActionOutcome, +): void { + const label = outcome.result === 'dry-run' ? '[DRY RUN]' : '[OK]'; + ctx.ok(`${label} ${outcome.kind} ${outcome.targetId}`); + ctx.muted(` Command: ${outcome.underlyingCommand}`); + for (const effect of outcome.sideEffects) { + ctx.muted(` Effect: ${effect}`); + } + if (outcome.patch) { + ctx.muted(` Patch: ${outcome.patch}`); + } + if (outcome.result === 'dry-run') { + return; + } + + const details = outcome.details ?? {}; + const detailKeys = Object.keys(details); + if (detailKeys.length > 0) { + ctx.print(''); + ctx.print('Result'); + for (const key of detailKeys.sort()) { + ctx.print(` ${key}: ${JSON.stringify(details[key])}`); + } + } +} + +export function registerAgentCommands(program: Command, ctx: CliContext): void { + const withErrorHandler = createErrorHandler(ctx); + + program + .command('act ') + .description('Execute a validated routine action through the agent action kernel') + .option('--dry-run', 'Validate and normalize without mutating graph or workspace') + .option('--description ', 'Quest description for shape') + .option('--kind ', `Quest kind for shape (${[...VALID_TASK_KINDS].join(' | ')})`) + .option('--story ', 'Story node ID for packet') + .option('--story-title ', 'Story title for packet') + .option('--persona ', 'Story persona for packet') + .option('--goal ', 'Story goal for packet') + .option('--benefit ', 'Story benefit for packet') + .option('--requirement ', 'Requirement node ID for packet') + .option('--requirement-description ', 'Requirement description for packet') + .option('--requirement-kind ', `Requirement kind (${[...VALID_REQUIREMENT_KINDS].join(' | ')})`) + .option('--priority ', `Requirement priority (${[...VALID_REQUIREMENT_PRIORITIES].join(' | ')})`) + .option('--criterion ', 'Criterion node ID for packet') + .option('--criterion-description ', 'Criterion description for packet') + .option('--no-verifiable', 'Mark a newly created criterion as not independently verifiable') + .option('--message ', 'Comment body for comment') + .option('--reply-to ', 'Reply target for comment') + .option('--comment-id ', 'Explicit comment ID for comment') + .action(withErrorHandler(async (actionKind: string, targetId: string, opts: ActOptions) => { + const service = new AgentActionService( + ctx.graphPort, + new WarpRoadmapAdapter(ctx.graphPort), + ctx.agentId, + ); + + const outcome = await service.execute({ + kind: actionKind, + targetId, + dryRun: opts.dryRun ?? false, + args: buildActionArgs(opts), + }); + + if (outcome.result === 'rejected') { + const reason = outcome.validation.reasons[0] ?? `Action '${actionKind}' was rejected`; + if (ctx.json) { + return ctx.failWithData(reason, { ...outcome }); + } + return ctx.fail(`[REJECTED] ${reason}`); + } + + if (ctx.json) { + ctx.jsonOut({ + success: true, + command: 'act', + data: { ...outcome }, + }); + return; + } + + renderHumanOutcome(ctx, outcome); + })); +} diff --git a/src/domain/services/AgentActionService.ts b/src/domain/services/AgentActionService.ts new file mode 100644 index 0000000..77958e4 --- /dev/null +++ b/src/domain/services/AgentActionService.ts @@ -0,0 +1,874 @@ +import { randomUUID } from 'node:crypto'; +import type { GraphPort } from '../../ports/GraphPort.js'; +import type { RoadmapQueryPort } from '../../ports/RoadmapPort.js'; +import { VALID_TASK_KINDS, type QuestKind } from '../entities/Quest.js'; +import { + VALID_REQUIREMENT_KINDS, + VALID_REQUIREMENT_PRIORITIES, + type RequirementKind, + type RequirementPriority, +} from '../entities/Requirement.js'; +import { IntakeService } from './IntakeService.js'; +import { ReadinessService } from './ReadinessService.js'; +import { createPatchSession } from '../../infrastructure/helpers/createPatchSession.js'; +import { WarpIntakeAdapter } from '../../infrastructure/adapters/WarpIntakeAdapter.js'; + +export const ROUTINE_AGENT_ACTION_KINDS = [ + 'claim', 'shape', 'packet', 'ready', 'comment', +] as const; + +export const HUMAN_ONLY_AGENT_ACTION_KINDS = [ + 'intent', 'promote', 'reject', 'reopen', 'depend', +] as const; + +export type RoutineAgentActionKind = typeof ROUTINE_AGENT_ACTION_KINDS[number]; +export type HumanOnlyAgentActionKind = typeof HUMAN_ONLY_AGENT_ACTION_KINDS[number]; +export type AgentActionKind = RoutineAgentActionKind | HumanOnlyAgentActionKind; + +export interface AgentActionRequest { + kind: string; + targetId: string; + dryRun?: boolean; + args: Record; +} + +export interface AgentActionValidation { + valid: boolean; + code: string | null; + reasons: string[]; +} + +export interface AgentActionAssessment { + kind: string; + targetId: string; + allowed: boolean; + dryRun: boolean; + requiresHumanApproval: boolean; + validation: AgentActionValidation; + normalizedArgs: Record; + underlyingCommand: string; + sideEffects: string[]; +} + +export interface AgentActionOutcome extends AgentActionAssessment { + result: 'dry-run' | 'success' | 'rejected'; + patch: string | null; + details: Record | null; +} + +interface ValidatedAssessment extends AgentActionAssessment { + normalizedAction?: SupportedNormalizedAction; +} + +interface ClaimAction { + kind: 'claim'; + targetId: string; +} + +interface ShapeAction { + kind: 'shape'; + targetId: string; + description?: string; + taskKind?: QuestKind; +} + +interface PacketAction { + kind: 'packet'; + targetId: string; + storyId: string; + storyTitle: string; + persona?: string; + goal?: string; + benefit?: string; + requirementId: string; + requirementDescription?: string; + requirementKind: RequirementKind; + priority: RequirementPriority; + criterionId: string; + criterionDescription?: string; + verifiable: boolean; +} + +interface ReadyAction { + kind: 'ready'; + targetId: string; +} + +interface CommentAction { + kind: 'comment'; + targetId: string; + commentId: string; + message: string; + replyTo?: string; + generatedId: boolean; +} + +type SupportedNormalizedAction = + | ClaimAction + | ShapeAction + | PacketAction + | ReadyAction + | CommentAction; + +function autoId(prefix: string): string { + const ts = Date.now().toString(36).padStart(9, '0'); + const rand = randomUUID().replace(/-/g, '').slice(0, 8); + return `${prefix}${ts}${rand}`; +} + +function isRoutineAgentActionKind(kind: string): kind is RoutineAgentActionKind { + return (ROUTINE_AGENT_ACTION_KINDS as readonly string[]).includes(kind); +} + +function isHumanOnlyAgentActionKind(kind: string): kind is HumanOnlyAgentActionKind { + return (HUMAN_ONLY_AGENT_ACTION_KINDS as readonly string[]).includes(kind); +} + +function failAssessment( + request: AgentActionRequest, + code: string, + reasons: string[], + opts?: { + requiresHumanApproval?: boolean; + normalizedArgs?: Record; + underlyingCommand?: string; + sideEffects?: string[]; + }, +): ValidatedAssessment { + return { + kind: request.kind, + targetId: request.targetId, + allowed: false, + dryRun: request.dryRun ?? false, + requiresHumanApproval: opts?.requiresHumanApproval ?? false, + validation: { + valid: false, + code, + reasons, + }, + normalizedArgs: opts?.normalizedArgs ?? {}, + underlyingCommand: opts?.underlyingCommand ?? `xyph ${request.kind} ${request.targetId}`, + sideEffects: opts?.sideEffects ?? [], + }; +} + +function successAssessment( + request: AgentActionRequest, + normalizedAction: SupportedNormalizedAction, + normalizedArgs: Record, + underlyingCommand: string, + sideEffects: string[], +): ValidatedAssessment { + return { + kind: request.kind, + targetId: request.targetId, + allowed: true, + dryRun: request.dryRun ?? false, + requiresHumanApproval: false, + validation: { + valid: true, + code: null, + reasons: [], + }, + normalizedArgs, + underlyingCommand, + sideEffects, + normalizedAction, + }; +} + +function derivePacketId(prefix: 'story:' | 'req:' | 'criterion:', questId: string): string { + return `${prefix}${questId.slice('task:'.length)}`; +} + +export class AgentActionValidator { + private readonly intake: IntakeService; + private readonly readiness: ReadinessService; + + constructor( + private readonly graphPort: GraphPort, + private readonly roadmap: RoadmapQueryPort, + private readonly agentId: string, + ) { + this.intake = new IntakeService(roadmap); + this.readiness = new ReadinessService(roadmap); + } + + public async validate(request: AgentActionRequest): Promise { + if (isHumanOnlyAgentActionKind(request.kind)) { + return failAssessment( + request, + 'human-only-action', + [`Action '${request.kind}' is reserved for human principals in checkpoint 2.`], + { requiresHumanApproval: true }, + ); + } + + if (!isRoutineAgentActionKind(request.kind)) { + return failAssessment( + request, + 'unsupported-action', + [`Action '${request.kind}' is not supported by the v1 action kernel.`], + ); + } + + switch (request.kind) { + case 'claim': + return this.validateClaim(request); + case 'shape': + return this.validateShape(request); + case 'packet': + return this.validatePacket(request); + case 'ready': + return this.validateReady(request); + case 'comment': + return this.validateComment(request); + } + } + + private async validateClaim(request: AgentActionRequest): Promise { + if (!request.targetId.startsWith('task:')) { + return failAssessment(request, 'invalid-target', [ + `claim requires a task:* target, got '${request.targetId}'`, + ]); + } + + const quest = await this.roadmap.getQuest(request.targetId); + if (quest === null) { + return failAssessment(request, 'not-found', [ + `Quest ${request.targetId} not found in the graph`, + ]); + } + if (quest.status !== 'READY') { + return failAssessment(request, 'precondition-failed', [ + `claim requires status READY, quest ${request.targetId} is ${quest.status}`, + ]); + } + + return successAssessment( + request, + { kind: 'claim', targetId: request.targetId }, + {}, + `xyph claim ${request.targetId}`, + [ + `assigned_to -> ${this.agentId}`, + 'status -> IN_PROGRESS', + 'claimed_at -> now', + ], + ); + } + + private async validateShape(request: AgentActionRequest): Promise { + if (!request.targetId.startsWith('task:')) { + return failAssessment(request, 'invalid-target', [ + `shape requires a task:* target, got '${request.targetId}'`, + ]); + } + + const descriptionRaw = typeof request.args['description'] === 'string' + ? request.args['description'].trim() + : undefined; + const taskKindRaw = request.args['taskKind']; + const taskKind = typeof taskKindRaw === 'string' ? taskKindRaw : undefined; + + if (descriptionRaw === undefined && taskKind === undefined) { + return failAssessment(request, 'invalid-args', [ + 'shape requires description and/or taskKind', + ]); + } + if (descriptionRaw !== undefined && descriptionRaw.length < 5) { + return failAssessment(request, 'invalid-args', [ + 'description must be at least 5 characters', + ]); + } + if (taskKind !== undefined && !VALID_TASK_KINDS.has(taskKind)) { + return failAssessment(request, 'invalid-args', [ + `taskKind must be one of ${[...VALID_TASK_KINDS].join(', ')}`, + ]); + } + + try { + await this.intake.validateShape(request.targetId); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return failAssessment(request, 'precondition-failed', [msg], { + normalizedArgs: { + description: descriptionRaw ?? null, + taskKind: taskKind ?? null, + }, + underlyingCommand: `xyph shape ${request.targetId}`, + sideEffects: [ + ...(descriptionRaw !== undefined ? ['description -> updated'] : []), + ...(taskKind !== undefined ? ['task_kind -> updated'] : []), + ], + }); + } + + return successAssessment( + request, + { + kind: 'shape', + targetId: request.targetId, + description: descriptionRaw, + taskKind: taskKind as QuestKind | undefined, + }, + { + description: descriptionRaw ?? null, + taskKind: taskKind ?? null, + }, + `xyph shape ${request.targetId}`, + [ + ...(descriptionRaw !== undefined ? ['description -> updated'] : []), + ...(taskKind !== undefined ? ['task_kind -> updated'] : []), + ], + ); + } + + private async validatePacket(request: AgentActionRequest): Promise { + if (!request.targetId.startsWith('task:')) { + return failAssessment(request, 'invalid-target', [ + `packet requires a task:* target, got '${request.targetId}'`, + ]); + } + + const quest = await this.roadmap.getQuest(request.targetId); + if (quest === null) { + return failAssessment(request, 'not-found', [ + `Quest ${request.targetId} not found in the graph`, + ]); + } + + const storyId = typeof request.args['storyId'] === 'string' + ? request.args['storyId'] + : derivePacketId('story:', request.targetId); + const requirementId = typeof request.args['requirementId'] === 'string' + ? request.args['requirementId'] + : derivePacketId('req:', request.targetId); + const criterionId = typeof request.args['criterionId'] === 'string' + ? request.args['criterionId'] + : derivePacketId('criterion:', request.targetId); + + if (!storyId.startsWith('story:')) { + return failAssessment(request, 'invalid-args', [`storyId must start with 'story:'`]); + } + if (!requirementId.startsWith('req:')) { + return failAssessment(request, 'invalid-args', [`requirementId must start with 'req:'`]); + } + if (!criterionId.startsWith('criterion:')) { + return failAssessment(request, 'invalid-args', [`criterionId must start with 'criterion:'`]); + } + + const requirementKind = typeof request.args['requirementKind'] === 'string' + ? request.args['requirementKind'] + : 'functional'; + const priority = typeof request.args['priority'] === 'string' + ? request.args['priority'] + : 'must'; + if (!VALID_REQUIREMENT_KINDS.has(requirementKind)) { + return failAssessment(request, 'invalid-args', [ + `requirementKind must be one of ${[...VALID_REQUIREMENT_KINDS].join(', ')}`, + ]); + } + if (!VALID_REQUIREMENT_PRIORITIES.has(priority)) { + return failAssessment(request, 'invalid-args', [ + `priority must be one of ${[...VALID_REQUIREMENT_PRIORITIES].join(', ')}`, + ]); + } + + const storyTitle = typeof request.args['storyTitle'] === 'string' + ? request.args['storyTitle'].trim() + : quest.title; + const persona = typeof request.args['persona'] === 'string' + ? request.args['persona'].trim() + : undefined; + const goal = typeof request.args['goal'] === 'string' + ? request.args['goal'].trim() + : undefined; + const benefit = typeof request.args['benefit'] === 'string' + ? request.args['benefit'].trim() + : undefined; + const requirementDescription = typeof request.args['requirementDescription'] === 'string' + ? request.args['requirementDescription'].trim() + : undefined; + const criterionDescription = typeof request.args['criterionDescription'] === 'string' + ? request.args['criterionDescription'].trim() + : undefined; + const verifiable = request.args['verifiable'] === false ? false : true; + + const graph = await this.graphPort.getGraph(); + const [storyExists, requirementExists, criterionExists] = await Promise.all([ + graph.hasNode(storyId), + graph.hasNode(requirementId), + graph.hasNode(criterionId), + ]); + + const reasons: string[] = []; + if (!storyExists) { + if (storyTitle.length < 5) reasons.push('storyTitle must be at least 5 characters when creating a story'); + if (!persona || persona.length < 2) reasons.push('persona is required when creating a story'); + if (!goal || goal.length < 5) reasons.push('goal is required when creating a story'); + if (!benefit || benefit.length < 5) reasons.push('benefit is required when creating a story'); + } + if (!requirementExists && (!requirementDescription || requirementDescription.length < 5)) { + reasons.push('requirementDescription is required when creating a requirement'); + } + if (!criterionExists && (!criterionDescription || criterionDescription.length < 5)) { + reasons.push('criterionDescription is required when creating a criterion'); + } + if (reasons.length > 0) { + return failAssessment(request, 'invalid-args', reasons, { + normalizedArgs: { + storyId, + requirementId, + criterionId, + storyTitle, + persona: persona ?? null, + goal: goal ?? null, + benefit: benefit ?? null, + requirementDescription: requirementDescription ?? null, + requirementKind, + priority, + criterionDescription: criterionDescription ?? null, + verifiable, + }, + underlyingCommand: `xyph packet ${request.targetId}`, + sideEffects: [ + `story -> ${storyExists ? 'link' : 'create'}`, + `requirement -> ${requirementExists ? 'link' : 'create'}`, + `criterion -> ${criterionExists ? 'link' : 'create'}`, + 'align traceability edges', + ], + }); + } + + return successAssessment( + request, + { + kind: 'packet', + targetId: request.targetId, + storyId, + storyTitle, + persona, + goal, + benefit, + requirementId, + requirementDescription, + requirementKind: requirementKind as RequirementKind, + priority: priority as RequirementPriority, + criterionId, + criterionDescription, + verifiable, + }, + { + storyId, + requirementId, + criterionId, + storyTitle, + persona: persona ?? null, + goal: goal ?? null, + benefit: benefit ?? null, + requirementDescription: requirementDescription ?? null, + requirementKind, + priority, + criterionDescription: criterionDescription ?? null, + verifiable, + }, + `xyph packet ${request.targetId}`, + [ + `story -> ${storyExists ? 'link' : 'create'}`, + `requirement -> ${requirementExists ? 'link' : 'create'}`, + `criterion -> ${criterionExists ? 'link' : 'create'}`, + 'align traceability edges', + ], + ); + } + + private async validateReady(request: AgentActionRequest): Promise { + if (!request.targetId.startsWith('task:')) { + return failAssessment(request, 'invalid-target', [ + `ready requires a task:* target, got '${request.targetId}'`, + ]); + } + + const assessment = await this.readiness.assess(request.targetId); + if (!assessment.valid) { + return failAssessment( + request, + 'precondition-failed', + assessment.unmet.map((item) => item.message), + { + normalizedArgs: {}, + underlyingCommand: `xyph ready ${request.targetId}`, + sideEffects: [ + 'status -> READY', + `ready_by -> ${this.agentId}`, + 'ready_at -> now', + ], + }, + ); + } + + return successAssessment( + request, + { kind: 'ready', targetId: request.targetId }, + {}, + `xyph ready ${request.targetId}`, + [ + 'status -> READY', + `ready_by -> ${this.agentId}`, + 'ready_at -> now', + ], + ); + } + + private async validateComment(request: AgentActionRequest): Promise { + const message = typeof request.args['message'] === 'string' + ? request.args['message'].trim() + : ''; + if (message.length < 1) { + return failAssessment(request, 'invalid-args', [ + 'comment requires a non-empty message', + ]); + } + + const replyTo = typeof request.args['replyTo'] === 'string' + ? request.args['replyTo'] + : undefined; + if (replyTo !== undefined && !replyTo.startsWith('comment:')) { + return failAssessment(request, 'invalid-args', [ + `replyTo must start with 'comment:', got '${replyTo}'`, + ]); + } + + const providedCommentId = typeof request.args['commentId'] === 'string' && request.args['commentId'].trim().length > 0 + ? request.args['commentId'].trim() + : undefined; + const commentId = providedCommentId ?? autoId('comment:'); + if (!commentId.startsWith('comment:')) { + return failAssessment(request, 'invalid-args', [ + `commentId must start with 'comment:', got '${commentId}'`, + ]); + } + + const graph = await this.graphPort.getGraph(); + if (!await graph.hasNode(request.targetId)) { + return failAssessment(request, 'not-found', [ + `Target ${request.targetId} not found in the graph`, + ]); + } + if (replyTo !== undefined && !await graph.hasNode(replyTo)) { + return failAssessment(request, 'not-found', [ + `Reply target ${replyTo} not found in the graph`, + ]); + } + + return successAssessment( + request, + { + kind: 'comment', + targetId: request.targetId, + commentId, + message, + replyTo, + generatedId: providedCommentId === undefined, + }, + { + commentId, + message, + replyTo: replyTo ?? null, + }, + `xyph comment ${commentId} --on ${request.targetId}`, + [ + `create ${commentId}`, + `comments-on -> ${request.targetId}`, + ...(replyTo ? [`replies-to -> ${replyTo}`] : []), + 'attach content blob', + ], + ); + } +} + +export class AgentActionService { + private readonly validator: AgentActionValidator; + + constructor( + private readonly graphPort: GraphPort, + private readonly roadmap: RoadmapQueryPort, + private readonly agentId: string, + ) { + this.validator = new AgentActionValidator(graphPort, roadmap, agentId); + } + + public async execute(request: AgentActionRequest): Promise { + const assessment = await this.validator.validate(request); + if (!assessment.allowed) { + return { + ...assessment, + result: 'rejected', + patch: null, + details: null, + }; + } + + if (assessment.dryRun) { + return { + ...assessment, + result: 'dry-run', + patch: null, + details: null, + }; + } + + const normalized = assessment.normalizedAction; + if (!normalized) { + return { + ...assessment, + allowed: false, + validation: { + valid: false, + code: 'execution-failed', + reasons: ['Action was not normalized for execution'], + }, + result: 'rejected', + patch: null, + details: null, + }; + } + + try { + switch (normalized.kind) { + case 'claim': + return await this.executeClaim(assessment, normalized); + case 'shape': + return await this.executeShape(assessment, normalized); + case 'packet': + return await this.executePacket(assessment, normalized); + case 'ready': + return await this.executeReady(assessment, normalized); + case 'comment': + return await this.executeComment(assessment, normalized); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { + ...assessment, + allowed: false, + validation: { + valid: false, + code: 'execution-failed', + reasons: [msg], + }, + result: 'rejected', + patch: null, + details: null, + }; + } + } + + private async executeClaim( + assessment: ValidatedAssessment, + action: ClaimAction, + ): Promise { + const graph = await this.graphPort.getGraph(); + const sha = await graph.patch((p) => { + p.setProperty(action.targetId, 'assigned_to', this.agentId) + .setProperty(action.targetId, 'status', 'IN_PROGRESS') + .setProperty(action.targetId, 'claimed_at', Date.now()); + }); + + const props = await graph.getNodeProps(action.targetId); + const confirmed = !!(props && props['assigned_to'] === this.agentId); + if (!confirmed) { + const winner = props ? String(props['assigned_to']) : 'unknown'; + return { + ...assessment, + allowed: false, + validation: { + valid: false, + code: 'claim-race-lost', + reasons: [`Lost race condition for ${action.targetId}. Current owner: ${winner}`], + }, + result: 'rejected', + patch: null, + details: { + currentOwner: winner, + }, + }; + } + + return { + ...assessment, + result: 'success', + patch: sha, + details: { + id: action.targetId, + assignedTo: this.agentId, + status: 'IN_PROGRESS', + }, + }; + } + + private async executeShape( + assessment: ValidatedAssessment, + action: ShapeAction, + ): Promise { + const intake = new WarpIntakeAdapter(this.graphPort, this.agentId); + const sha = await intake.shape(action.targetId, { + description: action.description, + taskKind: action.taskKind, + }); + const graph = await this.graphPort.getGraph(); + const props = await graph.getNodeProps(action.targetId); + + return { + ...assessment, + result: 'success', + patch: sha, + details: { + id: action.targetId, + status: typeof props?.['status'] === 'string' ? props['status'] : null, + description: typeof props?.['description'] === 'string' ? props['description'] : null, + taskKind: typeof props?.['task_kind'] === 'string' ? props['task_kind'] : null, + }, + }; + } + + private async executePacket( + assessment: ValidatedAssessment, + action: PacketAction, + ): Promise { + const graph = await this.graphPort.getGraph(); + const [storyExists, requirementExists, criterionExists] = await Promise.all([ + graph.hasNode(action.storyId), + graph.hasNode(action.requirementId), + graph.hasNode(action.criterionId), + ]); + + const questOutgoing = await this.roadmap.getOutgoingEdges(action.targetId); + const storyOutgoing = storyExists ? await this.roadmap.getOutgoingEdges(action.storyId) : []; + const storyIncoming = storyExists ? await this.roadmap.getIncomingEdges(action.storyId) : []; + const requirementOutgoing = requirementExists ? await this.roadmap.getOutgoingEdges(action.requirementId) : []; + + const intentId = questOutgoing.find((edge) => edge.type === 'authorized-by' && edge.to.startsWith('intent:'))?.to ?? null; + const hasIntentToStory = intentId === null + ? false + : storyIncoming.some((edge) => edge.type === 'decomposes-to' && edge.from === intentId); + const hasStoryToRequirement = storyOutgoing.some((edge) => edge.type === 'decomposes-to' && edge.to === action.requirementId); + const hasQuestToRequirement = questOutgoing.some((edge) => edge.type === 'implements' && edge.to === action.requirementId); + const hasRequirementToCriterion = requirementOutgoing.some((edge) => edge.type === 'has-criterion' && edge.to === action.criterionId); + const now = Date.now(); + + const sha = await graph.patch((p) => { + if (!storyExists) { + p.addNode(action.storyId) + .setProperty(action.storyId, 'title', action.storyTitle) + .setProperty(action.storyId, 'persona', action.persona as string) + .setProperty(action.storyId, 'goal', action.goal as string) + .setProperty(action.storyId, 'benefit', action.benefit as string) + .setProperty(action.storyId, 'created_by', this.agentId) + .setProperty(action.storyId, 'created_at', now) + .setProperty(action.storyId, 'type', 'story'); + } + + if (!requirementExists) { + p.addNode(action.requirementId) + .setProperty(action.requirementId, 'description', action.requirementDescription as string) + .setProperty(action.requirementId, 'kind', action.requirementKind) + .setProperty(action.requirementId, 'priority', action.priority) + .setProperty(action.requirementId, 'type', 'requirement'); + } + + if (!criterionExists) { + p.addNode(action.criterionId) + .setProperty(action.criterionId, 'description', action.criterionDescription as string) + .setProperty(action.criterionId, 'verifiable', action.verifiable) + .setProperty(action.criterionId, 'type', 'criterion'); + } + + if (intentId !== null && (!storyExists || !hasIntentToStory)) { + p.addEdge(intentId, action.storyId, 'decomposes-to'); + } + if (!hasStoryToRequirement) { + p.addEdge(action.storyId, action.requirementId, 'decomposes-to'); + } + if (!hasQuestToRequirement) { + p.addEdge(action.targetId, action.requirementId, 'implements'); + } + if (!hasRequirementToCriterion) { + p.addEdge(action.requirementId, action.criterionId, 'has-criterion'); + } + }); + + return { + ...assessment, + result: 'success', + patch: sha, + details: { + quest: action.targetId, + intent: intentId, + story: { id: action.storyId, created: !storyExists }, + requirement: { id: action.requirementId, created: !requirementExists }, + criterion: { id: action.criterionId, created: !criterionExists }, + }, + }; + } + + private async executeReady( + assessment: ValidatedAssessment, + action: ReadyAction, + ): Promise { + const intake = new WarpIntakeAdapter(this.graphPort, this.agentId); + const sha = await intake.ready(action.targetId); + const graph = await this.graphPort.getGraph(); + const props = await graph.getNodeProps(action.targetId); + const readyAt = typeof props?.['ready_at'] === 'number' ? props['ready_at'] : null; + + return { + ...assessment, + result: 'success', + patch: sha, + details: { + id: action.targetId, + status: 'READY', + readyBy: this.agentId, + readyAt, + }, + }; + } + + private async executeComment( + assessment: ValidatedAssessment, + action: CommentAction, + ): Promise { + const graph = await this.graphPort.getGraph(); + const patch = await createPatchSession(graph); + const now = Date.now(); + patch + .addNode(action.commentId) + .setProperty(action.commentId, 'type', 'comment') + .setProperty(action.commentId, 'authored_by', this.agentId) + .setProperty(action.commentId, 'authored_at', now) + .addEdge(action.commentId, action.targetId, 'comments-on'); + if (action.replyTo) { + patch.addEdge(action.commentId, action.replyTo, 'replies-to'); + } + await patch.attachContent(action.commentId, action.message); + const sha = await patch.commit(); + const contentOid = await graph.getContentOid(action.commentId) ?? undefined; + + return { + ...assessment, + result: 'success', + patch: sha, + details: { + id: action.commentId, + on: action.targetId, + replyTo: action.replyTo ?? null, + generatedId: action.generatedId, + authoredBy: this.agentId, + authoredAt: now, + contentOid: contentOid ?? null, + }, + }; + } +} diff --git a/test/unit/AgentActionService.test.ts b/test/unit/AgentActionService.test.ts new file mode 100644 index 0000000..e4686ee --- /dev/null +++ b/test/unit/AgentActionService.test.ts @@ -0,0 +1,211 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { Quest } from '../../src/domain/entities/Quest.js'; +import type { GraphPort } from '../../src/ports/GraphPort.js'; +import type { RoadmapQueryPort } from '../../src/ports/RoadmapPort.js'; +import { AgentActionService } from '../../src/domain/services/AgentActionService.js'; + +const mocks = vi.hoisted(() => ({ + createPatchSession: vi.fn(), +})); + +vi.mock('../../src/infrastructure/helpers/createPatchSession.js', () => ({ + createPatchSession: (graph: unknown) => mocks.createPatchSession(graph), +})); + +function makeQuest(overrides?: Partial[0]>): Quest { + return new Quest({ + id: 'task:AGT-001', + title: 'Agent kernel quest', + status: 'READY', + hours: 2, + description: 'Quest is structured enough for agent action tests.', + type: 'task', + ...overrides, + }); +} + +function makeRoadmap( + quest: Quest | null, + outgoingByNode: Record = {}, + incomingByNode: Record = {}, +): RoadmapQueryPort { + return { + getQuests: vi.fn(), + getQuest: vi.fn(async (id: string) => (id === quest?.id ? quest : null)), + getOutgoingEdges: vi.fn(async (nodeId: string) => outgoingByNode[nodeId] ?? []), + getIncomingEdges: vi.fn(async (nodeId: string) => incomingByNode[nodeId] ?? []), + }; +} + +function makeGraphPort(graph: Record): GraphPort { + return { + getGraph: vi.fn(async () => graph), + reset: vi.fn(), + }; +} + +function makePatchSession() { + return { + addNode: vi.fn().mockReturnThis(), + setProperty: vi.fn().mockReturnThis(), + addEdge: vi.fn().mockReturnThis(), + attachContent: vi.fn(async () => undefined), + commit: vi.fn(async () => 'patch:comment'), + }; +} + +describe('AgentActionService', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('rejects human-only actions with an explicit machine-readable reason', async () => { + const service = new AgentActionService( + makeGraphPort({}), + makeRoadmap(makeQuest()), + 'agent.hal', + ); + + const outcome = await service.execute({ + kind: 'promote', + targetId: 'task:AGT-001', + dryRun: true, + args: {}, + }); + + expect(outcome).toMatchObject({ + kind: 'promote', + targetId: 'task:AGT-001', + allowed: false, + requiresHumanApproval: true, + result: 'rejected', + validation: { + valid: false, + code: 'human-only-action', + }, + }); + }); + + it('supports dry-run claim with normalized side effects', async () => { + const service = new AgentActionService( + makeGraphPort({}), + makeRoadmap(makeQuest({ status: 'READY' })), + 'agent.hal', + ); + + const outcome = await service.execute({ + kind: 'claim', + targetId: 'task:AGT-001', + dryRun: true, + args: {}, + }); + + expect(outcome).toMatchObject({ + kind: 'claim', + targetId: 'task:AGT-001', + allowed: true, + dryRun: true, + result: 'dry-run', + underlyingCommand: 'xyph claim task:AGT-001', + patch: null, + }); + expect(outcome.sideEffects).toEqual([ + 'assigned_to -> agent.hal', + 'status -> IN_PROGRESS', + 'claimed_at -> now', + ]); + }); + + it('normalizes packet creation during dry-run without mutating the graph', async () => { + const graph = { + hasNode: vi.fn(async (id: string) => id === 'task:AGT-001'), + }; + const service = new AgentActionService( + makeGraphPort(graph), + makeRoadmap(makeQuest({ + status: 'PLANNED', + title: 'Traceability packet quest', + })), + 'agent.hal', + ); + + const outcome = await service.execute({ + kind: 'packet', + targetId: 'task:AGT-001', + dryRun: true, + args: { + persona: 'Maintainer', + goal: 'shape work through XYPH before execution', + benefit: 'READY becomes a truthful ceremony', + requirementDescription: 'A quest can be packetized with one agent-native action.', + criterionDescription: 'The packet includes a real criterion node.', + }, + }); + + expect(outcome).toMatchObject({ + kind: 'packet', + targetId: 'task:AGT-001', + allowed: true, + result: 'dry-run', + }); + expect(outcome.normalizedArgs).toMatchObject({ + storyId: 'story:AGT-001', + requirementId: 'req:AGT-001', + criterionId: 'criterion:AGT-001', + persona: 'Maintainer', + goal: 'shape work through XYPH before execution', + benefit: 'READY becomes a truthful ceremony', + verifiable: true, + }); + expect(graph.hasNode).toHaveBeenCalledWith('story:AGT-001'); + expect(graph.hasNode).toHaveBeenCalledWith('req:AGT-001'); + expect(graph.hasNode).toHaveBeenCalledWith('criterion:AGT-001'); + }); + + it('writes append-only graph-native comments on successful execution', async () => { + const graph = { + hasNode: vi.fn(async (id: string) => id === 'task:AGT-001'), + getContentOid: vi.fn(async () => 'oid:comment'), + }; + const patch = makePatchSession(); + mocks.createPatchSession.mockResolvedValue(patch); + + const service = new AgentActionService( + makeGraphPort(graph), + makeRoadmap(makeQuest()), + 'agent.hal', + ); + + const outcome = await service.execute({ + kind: 'comment', + targetId: 'task:AGT-001', + args: { + commentId: 'comment:AGT-001-1', + message: 'Leaving a durable note through the action kernel.', + }, + }); + + expect(patch.addNode).toHaveBeenCalledWith('comment:AGT-001-1'); + expect(patch.setProperty).toHaveBeenCalledWith('comment:AGT-001-1', 'type', 'comment'); + expect(patch.addEdge).toHaveBeenCalledWith('comment:AGT-001-1', 'task:AGT-001', 'comments-on'); + expect(patch.attachContent).toHaveBeenCalledWith( + 'comment:AGT-001-1', + 'Leaving a durable note through the action kernel.', + ); + expect(outcome).toMatchObject({ + kind: 'comment', + targetId: 'task:AGT-001', + allowed: true, + result: 'success', + patch: 'patch:comment', + details: { + id: 'comment:AGT-001-1', + on: 'task:AGT-001', + replyTo: null, + generatedId: false, + authoredBy: 'agent.hal', + contentOid: 'oid:comment', + }, + }); + }); +}); diff --git a/test/unit/AgentCommands.test.ts b/test/unit/AgentCommands.test.ts new file mode 100644 index 0000000..b74f2e3 --- /dev/null +++ b/test/unit/AgentCommands.test.ts @@ -0,0 +1,214 @@ +import { Command } from 'commander'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { CliContext } from '../../src/cli/context.js'; +import { registerAgentCommands } from '../../src/cli/commands/agent.js'; + +const mocks = vi.hoisted(() => ({ + execute: vi.fn(), + WarpRoadmapAdapter: vi.fn(), +})); + +vi.mock('../../src/domain/services/AgentActionService.js', () => ({ + AgentActionService: class AgentActionService { + execute(request: unknown) { + return mocks.execute(request); + } + }, +})); + +vi.mock('../../src/infrastructure/adapters/WarpRoadmapAdapter.js', () => ({ + WarpRoadmapAdapter: function WarpRoadmapAdapter(graphPort: unknown) { + mocks.WarpRoadmapAdapter(graphPort); + }, +})); + +function makeCtx(): CliContext { + return { + agentId: 'agent.hal', + identity: { agentId: 'agent.hal', source: 'default', origin: null }, + json: true, + graphPort: {} as CliContext['graphPort'], + style: {} as CliContext['style'], + ok: vi.fn(), + warn: vi.fn(), + muted: vi.fn(), + print: vi.fn(), + fail: vi.fn((msg: string) => { + throw new Error(msg); + }), + failWithData: vi.fn(), + jsonOut: vi.fn(), + } as unknown as CliContext; +} + +describe('agent act command', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('emits the action-kernel JSON envelope for a dry-run claim', async () => { + mocks.execute.mockResolvedValue({ + kind: 'claim', + targetId: 'task:AGT-001', + allowed: true, + dryRun: true, + requiresHumanApproval: false, + validation: { + valid: true, + code: null, + reasons: [], + }, + normalizedArgs: {}, + underlyingCommand: 'xyph claim task:AGT-001', + sideEffects: ['assigned_to -> agent.hal'], + result: 'dry-run', + patch: null, + details: null, + }); + + const ctx = makeCtx(); + const program = new Command(); + registerAgentCommands(program, ctx); + + await program.parseAsync(['act', 'claim', 'task:AGT-001', '--dry-run'], { from: 'user' }); + + expect(mocks.WarpRoadmapAdapter).toHaveBeenCalledWith(ctx.graphPort); + expect(mocks.execute).toHaveBeenCalledWith({ + kind: 'claim', + targetId: 'task:AGT-001', + dryRun: true, + args: {}, + }); + expect(ctx.jsonOut).toHaveBeenCalledWith({ + success: true, + command: 'act', + data: { + kind: 'claim', + targetId: 'task:AGT-001', + allowed: true, + dryRun: true, + requiresHumanApproval: false, + validation: { + valid: true, + code: null, + reasons: [], + }, + normalizedArgs: {}, + underlyingCommand: 'xyph claim task:AGT-001', + sideEffects: ['assigned_to -> agent.hal'], + result: 'dry-run', + patch: null, + details: null, + }, + }); + }); + + it('maps packet options into normalized action args', async () => { + mocks.execute.mockResolvedValue({ + kind: 'packet', + targetId: 'task:AGT-001', + allowed: true, + dryRun: true, + requiresHumanApproval: false, + validation: { + valid: true, + code: null, + reasons: [], + }, + normalizedArgs: {}, + underlyingCommand: 'xyph packet task:AGT-001', + sideEffects: [], + result: 'dry-run', + patch: null, + details: null, + }); + + const ctx = makeCtx(); + const program = new Command(); + registerAgentCommands(program, ctx); + + await program.parseAsync([ + 'act', + 'packet', + 'task:AGT-001', + '--story', + 'story:AGT-001', + '--story-title', + 'Agent packet', + '--persona', + 'Maintainer', + '--goal', + 'prove readiness', + '--benefit', + 'agents can act safely', + '--requirement', + 'req:AGT-001', + '--requirement-description', + 'Action kernel can create a minimal packet.', + '--requirement-kind', + 'functional', + '--priority', + 'must', + '--criterion', + 'criterion:AGT-001', + '--criterion-description', + 'The packet includes at least one criterion.', + '--no-verifiable', + '--dry-run', + ], { from: 'user' }); + + expect(mocks.execute).toHaveBeenCalledWith({ + kind: 'packet', + targetId: 'task:AGT-001', + dryRun: true, + args: { + storyId: 'story:AGT-001', + storyTitle: 'Agent packet', + persona: 'Maintainer', + goal: 'prove readiness', + benefit: 'agents can act safely', + requirementId: 'req:AGT-001', + requirementDescription: 'Action kernel can create a minimal packet.', + requirementKind: 'functional', + priority: 'must', + criterionId: 'criterion:AGT-001', + criterionDescription: 'The packet includes at least one criterion.', + verifiable: false, + }, + }); + }); + + it('routes rejected actions through the JSON error envelope', async () => { + const rejected = { + kind: 'promote', + targetId: 'task:AGT-001', + allowed: false, + dryRun: true, + requiresHumanApproval: true, + validation: { + valid: false, + code: 'human-only-action', + reasons: ['Action \'promote\' is reserved for human principals in checkpoint 2.'], + }, + normalizedArgs: {}, + underlyingCommand: 'xyph promote task:AGT-001', + sideEffects: [], + result: 'rejected', + patch: null, + details: null, + }; + mocks.execute.mockResolvedValue(rejected); + + const ctx = makeCtx(); + const program = new Command(); + registerAgentCommands(program, ctx); + + await program.parseAsync(['act', 'promote', 'task:AGT-001', '--dry-run'], { from: 'user' }); + + expect(ctx.failWithData).toHaveBeenCalledWith( + "Action 'promote' is reserved for human principals in checkpoint 2.", + rejected, + ); + expect(ctx.jsonOut).not.toHaveBeenCalled(); + }); +}); diff --git a/xyph-actuator.ts b/xyph-actuator.ts index 41be086..3d25178 100755 --- a/xyph-actuator.ts +++ b/xyph-actuator.ts @@ -16,6 +16,7 @@ import { registerSuggestionCommands } from './src/cli/commands/suggestions.js'; import { registerAnalyzeCommands } from './src/cli/commands/analyze.js'; import { registerIdentityCommands } from './src/cli/commands/identity.js'; import { registerShowCommands } from './src/cli/commands/show.js'; +import { registerAgentCommands } from './src/cli/commands/agent.js'; // Best-effort pre-scan for --json before Commander parses. // createCliContext() handles theme init internally based on this flag. @@ -53,5 +54,6 @@ registerSuggestionCommands(program, ctx); registerAnalyzeCommands(program, ctx); registerIdentityCommands(program, ctx); registerShowCommands(program, ctx); +registerAgentCommands(program, ctx); await program.parseAsync(process.argv); From 68fa1fe37005c85f341fe3e2b8cfcbc3c91f6683 Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 12 Mar 2026 12:46:39 -0700 Subject: [PATCH 05/22] Add agent context work packets --- docs/canonical/AGENT_PROTOCOL.md | 4 +- src/cli/commands/agent.ts | 137 +++++++++++ src/domain/services/AgentContextService.ts | 125 ++++++++++ src/domain/services/AgentRecommender.ts | 131 ++++++++++ test/unit/AgentCommands.test.ts | 144 +++++++++++ test/unit/AgentContextService.test.ts | 267 +++++++++++++++++++++ 6 files changed, 807 insertions(+), 1 deletion(-) create mode 100644 src/domain/services/AgentContextService.ts create mode 100644 src/domain/services/AgentRecommender.ts create mode 100644 test/unit/AgentContextService.test.ts diff --git a/docs/canonical/AGENT_PROTOCOL.md b/docs/canonical/AGENT_PROTOCOL.md index 0c8d593..367d92e 100644 --- a/docs/canonical/AGENT_PROTOCOL.md +++ b/docs/canonical/AGENT_PROTOCOL.md @@ -58,8 +58,10 @@ and response contract. Current runtime tranche: +- shipped now: `context ` - shipped now: `claim`, `shape`, `packet`, `ready`, `comment` -- planned later in checkpoint 2: `submit`, `review`, `handoff`, `seal`, `merge` +- shipped now: `act ` for that subset +- planned later in checkpoint 2: `briefing`, `next`, `submissions`, `handoff`, `submit`, `review`, `seal`, `merge` ### 3.1 `show` vs `context` diff --git a/src/cli/commands/agent.ts b/src/cli/commands/agent.ts index 4c8e42e..030a52e 100644 --- a/src/cli/commands/agent.ts +++ b/src/cli/commands/agent.ts @@ -11,6 +11,13 @@ import { AgentActionService, type AgentActionOutcome, } from '../../domain/services/AgentActionService.js'; +import { AgentContextService } from '../../domain/services/AgentContextService.js'; +import type { + AgentActionCandidate, + AgentDependencyContext, +} from '../../domain/services/AgentRecommender.js'; +import type { ReadinessAssessment } from '../../domain/services/ReadinessService.js'; +import type { EntityDetail } from '../../domain/models/dashboard.js'; interface ActOptions { dryRun?: boolean; @@ -87,9 +94,139 @@ function renderHumanOutcome( } } +function renderAgentContext( + detail: EntityDetail, + readiness: ReadinessAssessment | null, + dependency: AgentDependencyContext | null, + recommendedActions: AgentActionCandidate[], +): string { + const lines: string[] = []; + lines.push(`${detail.id} [${detail.type}]`); + + if (detail.questDetail) { + const quest = detail.questDetail.quest; + lines.push(`${quest.title} [${quest.status}]`); + lines.push(`kind: ${quest.taskKind ?? 'delivery'} hours: ${quest.hours}`); + if (quest.description) { + lines.push(''); + lines.push(quest.description); + } + + lines.push(''); + lines.push('Action Context'); + lines.push(` campaign: ${detail.questDetail.campaign?.id ?? '—'}`); + lines.push(` intent: ${detail.questDetail.intent?.id ?? '—'}`); + lines.push(` assigned: ${quest.assignedTo ?? '—'}`); + if (readiness) { + lines.push(` readiness: ${readiness.valid ? 'valid' : 'blocked'}`); + for (const unmet of readiness.unmet) { + lines.push(` - ${unmet.message}`); + } + } + if (dependency) { + lines.push(` executable: ${dependency.isExecutable ? 'yes' : 'no'}`); + lines.push(` frontier: ${dependency.isFrontier ? 'yes' : 'no'}`); + lines.push(` topoIndex: ${dependency.topologicalIndex ?? '—'}`); + lines.push(` downstream: ${dependency.transitiveDownstream}`); + if (dependency.dependsOn.length > 0) { + lines.push(` dependsOn: ${dependency.dependsOn.map((entry) => entry.id).join(', ')}`); + } + if (dependency.blockedBy.length > 0) { + lines.push(` blockedBy: ${dependency.blockedBy.map((entry) => entry.id).join(', ')}`); + } + if (dependency.dependents.length > 0) { + lines.push(` dependents: ${dependency.dependents.map((entry) => entry.id).join(', ')}`); + } + } + + if (detail.questDetail.submission) { + lines.push(''); + lines.push('Submission'); + lines.push(` latest: ${detail.questDetail.submission.id} (${detail.questDetail.submission.status})`); + lines.push(` reviews: ${detail.questDetail.reviews.length}`); + lines.push(` decisions: ${detail.questDetail.decisions.length}`); + } + + lines.push(''); + lines.push('Recommended Actions'); + if (recommendedActions.length === 0) { + lines.push(' none'); + } else { + for (const action of recommendedActions) { + const status = action.allowed ? 'allowed' : 'blocked'; + lines.push(` - ${action.kind} (${status})`); + lines.push(` ${action.reason}`); + if (action.blockedBy.length > 0) { + lines.push(` blockedBy: ${action.blockedBy.join(' | ')}`); + } + } + } + + return lines.join('\n'); + } + + const propKeys = Object.keys(detail.props).sort(); + if (propKeys.length > 0) { + lines.push(''); + lines.push('Properties'); + for (const key of propKeys) { + lines.push(` ${key}: ${JSON.stringify(detail.props[key])}`); + } + } + return lines.join('\n'); +} + export function registerAgentCommands(program: Command, ctx: CliContext): void { const withErrorHandler = createErrorHandler(ctx); + program + .command('context ') + .description('Build an action-oriented work packet for an entity') + .action(withErrorHandler(async (id: string) => { + const service = new AgentContextService( + ctx.graphPort, + new WarpRoadmapAdapter(ctx.graphPort), + ctx.agentId, + ); + const result = await service.fetch(id); + if (!result) { + if (ctx.json) { + return ctx.failWithData(`Node ${id} not found in the graph`, { id }); + } + return ctx.fail(`[NOT_FOUND] Node ${id} not found in the graph`); + } + + if (ctx.json) { + ctx.jsonOut({ + success: true, + command: 'context', + data: { + id: result.detail.id, + type: result.detail.type, + props: result.detail.props, + content: result.detail.content ?? null, + contentOid: result.detail.contentOid ?? null, + outgoing: result.detail.outgoing, + incoming: result.detail.incoming, + questDetail: result.detail.questDetail ?? null, + agentContext: { + readiness: result.readiness, + dependency: result.dependency, + recommendedActions: result.recommendedActions, + }, + }, + }); + return; + } + + ctx.print(renderAgentContext( + result.detail, + result.readiness, + result.dependency, + result.recommendedActions, + )); + })); + program .command('act ') .description('Execute a validated routine action through the agent action kernel') diff --git a/src/domain/services/AgentContextService.ts b/src/domain/services/AgentContextService.ts new file mode 100644 index 0000000..b3f1501 --- /dev/null +++ b/src/domain/services/AgentContextService.ts @@ -0,0 +1,125 @@ +import { isExecutableQuestStatus } from '../entities/Quest.js'; +import type { EntityDetail, GraphSnapshot, QuestNode } from '../models/dashboard.js'; +import type { GraphPort } from '../../ports/GraphPort.js'; +import type { RoadmapQueryPort } from '../../ports/RoadmapPort.js'; +import { createGraphContext } from '../../infrastructure/GraphContext.js'; +import { computeFrontier } from './DepAnalysis.js'; +import { ReadinessService, type ReadinessAssessment } from './ReadinessService.js'; +import { AgentActionValidator } from './AgentActionService.js'; +import { + AgentRecommender, + type AgentActionCandidate, + type AgentDependencyContext, + type AgentQuestRef, +} from './AgentRecommender.js'; + +export interface AgentContextResult { + detail: EntityDetail; + readiness: ReadinessAssessment | null; + dependency: AgentDependencyContext | null; + recommendedActions: AgentActionCandidate[]; +} + +function toQuestRef(quest: QuestNode): AgentQuestRef { + return { + id: quest.id, + title: quest.title, + status: quest.status, + hours: quest.hours, + taskKind: quest.taskKind, + assignedTo: quest.assignedTo, + }; +} + +function buildDependencyContext( + snapshot: GraphSnapshot, + quest: QuestNode, +): AgentDependencyContext { + const questById = new Map(snapshot.quests.map((entry) => [entry.id, entry] as const)); + const taskSummaries = snapshot.quests.map((entry) => ({ + id: entry.id, + status: entry.status, + hours: entry.hours, + })); + const depEdges = snapshot.quests.flatMap((entry) => + (entry.dependsOn ?? []).map((to) => ({ from: entry.id, to })), + ); + const frontierResult = computeFrontier(taskSummaries, depEdges); + + const dependsOn = (quest.dependsOn ?? []) + .map((id) => questById.get(id)) + .filter((entry): entry is QuestNode => Boolean(entry)) + .map(toQuestRef); + + const dependents = snapshot.quests + .filter((entry) => (entry.dependsOn ?? []).includes(quest.id)) + .map(toQuestRef) + .sort((a, b) => a.id.localeCompare(b.id)); + + const blockedBy = (frontierResult.blockedBy.get(quest.id) ?? []) + .map((id) => questById.get(id)) + .filter((entry): entry is QuestNode => Boolean(entry)) + .map(toQuestRef); + + const topoIndex = snapshot.sortedTaskIds.indexOf(quest.id); + + return { + isExecutable: isExecutableQuestStatus(quest.status), + isFrontier: frontierResult.frontier.includes(quest.id), + dependsOn, + dependents, + blockedBy, + topologicalIndex: topoIndex >= 0 ? topoIndex + 1 : null, + transitiveDownstream: snapshot.transitiveDownstream.get(quest.id) ?? 0, + }; +} + +export class AgentContextService { + private readonly readiness: ReadinessService; + private readonly recommender: AgentRecommender; + + constructor( + private readonly graphPort: GraphPort, + roadmap: RoadmapQueryPort, + agentId: string, + ) { + this.readiness = new ReadinessService(roadmap); + this.recommender = new AgentRecommender( + new AgentActionValidator(graphPort, roadmap, agentId), + ); + } + + public async fetch(id: string): Promise { + const graphCtx = createGraphContext(this.graphPort); + const snapshot = await graphCtx.fetchSnapshot(); + const detail = await graphCtx.fetchEntityDetail(id); + if (!detail) { + return null; + } + + if (!detail.questDetail) { + return { + detail, + readiness: null, + dependency: null, + recommendedActions: [], + }; + } + + const quest = detail.questDetail.quest; + const readiness = await this.readiness.assess(id, { transition: false }); + const dependency = buildDependencyContext(snapshot, quest); + const recommendedActions = await this.recommender.recommendForQuest( + quest, + readiness, + dependency, + ); + + return { + detail, + readiness, + dependency, + recommendedActions, + }; + } +} diff --git a/src/domain/services/AgentRecommender.ts b/src/domain/services/AgentRecommender.ts new file mode 100644 index 0000000..39b71be --- /dev/null +++ b/src/domain/services/AgentRecommender.ts @@ -0,0 +1,131 @@ +import type { ReadinessAssessment } from './ReadinessService.js'; +import type { QuestNode } from '../models/dashboard.js'; +import type { AgentActionRequest, AgentActionValidator } from './AgentActionService.js'; + +export interface AgentDependencyContext { + isExecutable: boolean; + isFrontier: boolean; + dependsOn: AgentQuestRef[]; + dependents: AgentQuestRef[]; + blockedBy: AgentQuestRef[]; + topologicalIndex: number | null; + transitiveDownstream: number; +} + +export interface AgentQuestRef { + id: string; + title: string; + status: string; + hours: number; + taskKind?: string; + assignedTo?: string; +} + +export interface AgentActionCandidate { + kind: string; + targetId: string; + args: Record; + reason: string; + confidence: number; + requiresHumanApproval: boolean; + dryRunSummary: string; + blockedBy: string[]; + allowed: boolean; + underlyingCommand: string; + sideEffects: string[]; + validationCode: string | null; +} + +interface CandidateSeed { + request: AgentActionRequest; + reason: string; + confidence: number; + dryRunSummary: string; +} + +export class AgentRecommender { + constructor(private readonly validator: AgentActionValidator) {} + + public async recommendForQuest( + quest: QuestNode, + readiness: ReadinessAssessment | null, + _dependency: AgentDependencyContext, + ): Promise { + const seeds: CandidateSeed[] = []; + + if (quest.status === 'READY') { + seeds.push({ + request: { + kind: 'claim', + targetId: quest.id, + dryRun: true, + args: {}, + }, + reason: 'Quest is in READY and can be claimed immediately.', + confidence: 0.98, + dryRunSummary: 'Move the quest into IN_PROGRESS and assign it to the current agent.', + }); + } + + if (quest.status === 'PLANNED' && readiness?.valid) { + seeds.push({ + request: { + kind: 'ready', + targetId: quest.id, + dryRun: true, + args: {}, + }, + reason: 'Quest satisfies the readiness contract and can enter the executable DAG.', + confidence: 0.97, + dryRunSummary: 'Move the quest into READY and record the readiness ceremony metadata.', + }); + } + + const unmetCodes = new Set((readiness?.unmet ?? []).map((item) => item.code)); + if ( + quest.status === 'PLANNED' && + ( + unmetCodes.has('missing-requirement') || + unmetCodes.has('missing-story') || + unmetCodes.has('missing-criterion') + ) + ) { + seeds.push({ + request: { + kind: 'packet', + targetId: quest.id, + dryRun: true, + args: {}, + }, + reason: 'Quest needs a traceability packet before it can pass READY.', + confidence: 0.84, + dryRunSummary: 'Create or link a story, requirement, and criterion chain for this quest.', + }); + } + + const candidates = await Promise.all(seeds.map(async (seed) => { + const assessment = await this.validator.validate(seed.request); + return { + kind: seed.request.kind, + targetId: seed.request.targetId, + args: assessment.normalizedArgs, + reason: seed.reason, + confidence: seed.confidence, + requiresHumanApproval: assessment.requiresHumanApproval, + dryRunSummary: seed.dryRunSummary, + blockedBy: assessment.allowed ? [] : assessment.validation.reasons, + allowed: assessment.allowed, + underlyingCommand: assessment.underlyingCommand, + sideEffects: assessment.sideEffects, + validationCode: assessment.validation.code, + } satisfies AgentActionCandidate; + })); + + candidates.sort((a, b) => + Number(b.allowed) - Number(a.allowed) || + b.confidence - a.confidence || + a.kind.localeCompare(b.kind), + ); + return candidates; + } +} diff --git a/test/unit/AgentCommands.test.ts b/test/unit/AgentCommands.test.ts index b74f2e3..02729bf 100644 --- a/test/unit/AgentCommands.test.ts +++ b/test/unit/AgentCommands.test.ts @@ -5,6 +5,7 @@ import { registerAgentCommands } from '../../src/cli/commands/agent.js'; const mocks = vi.hoisted(() => ({ execute: vi.fn(), + fetchContext: vi.fn(), WarpRoadmapAdapter: vi.fn(), })); @@ -16,6 +17,14 @@ vi.mock('../../src/domain/services/AgentActionService.js', () => ({ }, })); +vi.mock('../../src/domain/services/AgentContextService.js', () => ({ + AgentContextService: class AgentContextService { + fetch(id: string) { + return mocks.fetchContext(id); + } + }, +})); + vi.mock('../../src/infrastructure/adapters/WarpRoadmapAdapter.js', () => ({ WarpRoadmapAdapter: function WarpRoadmapAdapter(graphPort: unknown) { mocks.WarpRoadmapAdapter(graphPort); @@ -46,6 +55,141 @@ describe('agent act command', () => { vi.clearAllMocks(); }); + it('emits a JSON context packet for a quest target', async () => { + mocks.fetchContext.mockResolvedValue({ + detail: { + id: 'task:CTX-001', + type: 'task', + props: { type: 'task', title: 'Context quest' }, + content: null, + contentOid: null, + outgoing: [], + incoming: [], + questDetail: { + id: 'task:CTX-001', + quest: { + id: 'task:CTX-001', + title: 'Context quest', + status: 'READY', + hours: 2, + taskKind: 'delivery', + }, + reviews: [], + decisions: [], + stories: [], + requirements: [], + criteria: [], + evidence: [], + policies: [], + documents: [], + comments: [], + timeline: [], + }, + }, + readiness: { + valid: true, + questId: 'task:CTX-001', + taskKind: 'delivery', + unmet: [], + }, + dependency: { + isExecutable: true, + isFrontier: true, + dependsOn: [], + dependents: [], + blockedBy: [], + topologicalIndex: 1, + transitiveDownstream: 0, + }, + recommendedActions: [{ + kind: 'claim', + targetId: 'task:CTX-001', + args: {}, + reason: 'Quest is in READY and can be claimed immediately.', + confidence: 0.98, + requiresHumanApproval: false, + dryRunSummary: 'Move the quest into IN_PROGRESS and assign it to the current agent.', + blockedBy: [], + allowed: true, + underlyingCommand: 'xyph claim task:CTX-001', + sideEffects: ['status -> IN_PROGRESS'], + validationCode: null, + }], + }); + + const ctx = makeCtx(); + const program = new Command(); + registerAgentCommands(program, ctx); + + await program.parseAsync(['context', 'task:CTX-001'], { from: 'user' }); + + expect(mocks.fetchContext).toHaveBeenCalledWith('task:CTX-001'); + expect(ctx.jsonOut).toHaveBeenCalledWith({ + success: true, + command: 'context', + data: { + id: 'task:CTX-001', + type: 'task', + props: { type: 'task', title: 'Context quest' }, + content: null, + contentOid: null, + outgoing: [], + incoming: [], + questDetail: { + id: 'task:CTX-001', + quest: { + id: 'task:CTX-001', + title: 'Context quest', + status: 'READY', + hours: 2, + taskKind: 'delivery', + }, + reviews: [], + decisions: [], + stories: [], + requirements: [], + criteria: [], + evidence: [], + policies: [], + documents: [], + comments: [], + timeline: [], + }, + agentContext: { + readiness: { + valid: true, + questId: 'task:CTX-001', + taskKind: 'delivery', + unmet: [], + }, + dependency: { + isExecutable: true, + isFrontier: true, + dependsOn: [], + dependents: [], + blockedBy: [], + topologicalIndex: 1, + transitiveDownstream: 0, + }, + recommendedActions: [{ + kind: 'claim', + targetId: 'task:CTX-001', + args: {}, + reason: 'Quest is in READY and can be claimed immediately.', + confidence: 0.98, + requiresHumanApproval: false, + dryRunSummary: 'Move the quest into IN_PROGRESS and assign it to the current agent.', + blockedBy: [], + allowed: true, + underlyingCommand: 'xyph claim task:CTX-001', + sideEffects: ['status -> IN_PROGRESS'], + validationCode: null, + }], + }, + }, + }); + }); + it('emits the action-kernel JSON envelope for a dry-run claim', async () => { mocks.execute.mockResolvedValue({ kind: 'claim', diff --git a/test/unit/AgentContextService.test.ts b/test/unit/AgentContextService.test.ts new file mode 100644 index 0000000..669f384 --- /dev/null +++ b/test/unit/AgentContextService.test.ts @@ -0,0 +1,267 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { Quest } from '../../src/domain/entities/Quest.js'; +import type { RoadmapQueryPort } from '../../src/ports/RoadmapPort.js'; +import type { GraphPort } from '../../src/ports/GraphPort.js'; +import { makeSnapshot, quest, campaign, intent } from '../helpers/snapshot.js'; +import { AgentContextService } from '../../src/domain/services/AgentContextService.js'; + +const mocks = vi.hoisted(() => ({ + createGraphContext: vi.fn(), +})); + +vi.mock('../../src/infrastructure/GraphContext.js', () => ({ + createGraphContext: (graphPort: unknown) => mocks.createGraphContext(graphPort), +})); + +function makeRoadmap( + questEntity: Quest | null, + outgoingByNode: Record = {}, + incomingByNode: Record = {}, +): RoadmapQueryPort { + return { + getQuests: vi.fn(), + getQuest: vi.fn(async (id: string) => (id === questEntity?.id ? questEntity : null)), + getOutgoingEdges: vi.fn(async (nodeId: string) => outgoingByNode[nodeId] ?? []), + getIncomingEdges: vi.fn(async (nodeId: string) => incomingByNode[nodeId] ?? []), + }; +} + +function makeQuestEntity(overrides?: Partial[0]>): Quest { + return new Quest({ + id: 'task:CTX-001', + title: 'Context quest', + status: 'READY', + hours: 3, + description: 'Quest has enough structure to drive agent context.', + type: 'task', + ...overrides, + }); +} + +function makeGraphPort(): GraphPort { + return { + getGraph: vi.fn(), + reset: vi.fn(), + }; +} + +describe('AgentContextService', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('builds quest context with dependency state and a validated claim recommendation', async () => { + const snapshot = makeSnapshot({ + quests: [ + quest({ + id: 'task:CTX-001', + title: 'Context quest', + status: 'READY', + hours: 3, + description: 'Quest has enough structure to drive agent context.', + taskKind: 'delivery', + campaignId: 'campaign:TRACE', + intentId: 'intent:TRACE', + dependsOn: ['task:DEP-001'], + }), + quest({ + id: 'task:DEP-001', + title: 'Dependency quest', + status: 'DONE', + hours: 2, + taskKind: 'delivery', + }), + quest({ + id: 'task:DOWN-001', + title: 'Dependent quest', + status: 'PLANNED', + hours: 1, + taskKind: 'delivery', + dependsOn: ['task:CTX-001'], + }), + ], + campaigns: [ + campaign({ id: 'campaign:TRACE', title: 'Trace Campaign' }), + ], + intents: [ + intent({ id: 'intent:TRACE', title: 'Trace Intent' }), + ], + sortedTaskIds: ['task:DEP-001', 'task:CTX-001', 'task:DOWN-001'], + transitiveDownstream: new Map([['task:CTX-001', 1]]), + }); + + const detail = { + id: 'task:CTX-001', + type: 'task', + props: { type: 'task', title: 'Context quest' }, + content: null, + contentOid: null, + outgoing: [], + incoming: [], + questDetail: { + id: 'task:CTX-001', + quest: snapshot.quests[0] ?? (() => { throw new Error('missing quest fixture'); })(), + campaign: snapshot.campaigns[0], + intent: snapshot.intents[0], + reviews: [], + decisions: [], + stories: [], + requirements: [], + criteria: [], + evidence: [], + policies: [], + documents: [], + comments: [], + timeline: [], + }, + }; + + mocks.createGraphContext.mockReturnValue({ + fetchSnapshot: vi.fn().mockResolvedValue(snapshot), + fetchEntityDetail: vi.fn().mockResolvedValue(detail), + filterSnapshot: vi.fn(), + invalidateCache: vi.fn(), + get graph() { + throw new Error('not used in test'); + }, + }); + + const service = new AgentContextService( + makeGraphPort(), + makeRoadmap( + makeQuestEntity(), + { + 'task:CTX-001': [ + { type: 'authorized-by', to: 'intent:TRACE' }, + { type: 'belongs-to', to: 'campaign:TRACE' }, + { type: 'implements', to: 'req:CTX-001' }, + ], + 'req:CTX-001': [ + { type: 'has-criterion', to: 'criterion:CTX-001' }, + ], + }, + { + 'req:CTX-001': [ + { type: 'decomposes-to', from: 'story:CTX-001' }, + ], + }, + ), + 'agent.hal', + ); + + const result = await service.fetch('task:CTX-001'); + + expect(result).not.toBeNull(); + if (!result) { + throw new Error('expected result'); + } + expect(result.dependency).toMatchObject({ + isExecutable: true, + isFrontier: true, + topologicalIndex: 2, + transitiveDownstream: 1, + }); + expect(result.dependency?.dependsOn.map((entry) => entry.id)).toEqual(['task:DEP-001']); + expect(result.dependency?.dependents.map((entry) => entry.id)).toEqual(['task:DOWN-001']); + expect(result.recommendedActions[0]).toMatchObject({ + kind: 'claim', + targetId: 'task:CTX-001', + allowed: true, + blockedBy: [], + }); + }); + + it('recommends ready for a PLANNED quest whose contract is already satisfied', async () => { + const snapshot = makeSnapshot({ + quests: [ + quest({ + id: 'task:CTX-READY', + title: 'Readyable quest', + status: 'PLANNED', + hours: 2, + description: 'Everything is in place except the readiness transition.', + taskKind: 'delivery', + campaignId: 'campaign:TRACE', + intentId: 'intent:TRACE', + }), + ], + campaigns: [campaign({ id: 'campaign:TRACE', title: 'Trace Campaign' })], + intents: [intent({ id: 'intent:TRACE', title: 'Trace Intent' })], + sortedTaskIds: ['task:CTX-READY'], + }); + + const detail = { + id: 'task:CTX-READY', + type: 'task', + props: { type: 'task', title: 'Readyable quest' }, + content: null, + contentOid: null, + outgoing: [], + incoming: [], + questDetail: { + id: 'task:CTX-READY', + quest: snapshot.quests[0] ?? (() => { throw new Error('missing quest fixture'); })(), + campaign: snapshot.campaigns[0], + intent: snapshot.intents[0], + reviews: [], + decisions: [], + stories: [], + requirements: [], + criteria: [], + evidence: [], + policies: [], + documents: [], + comments: [], + timeline: [], + }, + }; + + mocks.createGraphContext.mockReturnValue({ + fetchSnapshot: vi.fn().mockResolvedValue(snapshot), + fetchEntityDetail: vi.fn().mockResolvedValue(detail), + filterSnapshot: vi.fn(), + invalidateCache: vi.fn(), + get graph() { + throw new Error('not used in test'); + }, + }); + + const service = new AgentContextService( + makeGraphPort(), + makeRoadmap( + makeQuestEntity({ + id: 'task:CTX-READY', + title: 'Readyable quest', + status: 'PLANNED', + hours: 2, + description: 'Everything is in place except the readiness transition.', + }), + { + 'task:CTX-READY': [ + { type: 'authorized-by', to: 'intent:TRACE' }, + { type: 'belongs-to', to: 'campaign:TRACE' }, + { type: 'implements', to: 'req:CTX-READY' }, + ], + 'req:CTX-READY': [ + { type: 'has-criterion', to: 'criterion:CTX-READY' }, + ], + }, + { + 'req:CTX-READY': [ + { type: 'decomposes-to', from: 'story:CTX-READY' }, + ], + }, + ), + 'agent.hal', + ); + + const result = await service.fetch('task:CTX-READY'); + + expect(result?.readiness?.valid).toBe(true); + expect(result?.recommendedActions[0]).toMatchObject({ + kind: 'ready', + targetId: 'task:CTX-READY', + allowed: true, + }); + }); +}); From c38a1091fe4a2d7859b0f3b8ace07b057c46dfbc Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 12 Mar 2026 13:10:49 -0700 Subject: [PATCH 06/22] Add agent briefing and next commands --- docs/canonical/AGENT_PROTOCOL.md | 4 +- src/cli/commands/agent.ts | 144 ++++++++++ src/domain/services/AgentBriefingService.ts | 254 ++++++++++++++++++ src/domain/services/AgentContextService.ts | 12 +- src/domain/services/AgentRecommender.ts | 4 +- test/unit/AgentBriefingService.test.ts | 281 ++++++++++++++++++++ test/unit/AgentCommands.test.ts | 116 ++++++++ 7 files changed, 806 insertions(+), 9 deletions(-) create mode 100644 src/domain/services/AgentBriefingService.ts create mode 100644 test/unit/AgentBriefingService.test.ts diff --git a/docs/canonical/AGENT_PROTOCOL.md b/docs/canonical/AGENT_PROTOCOL.md index 367d92e..1a970c5 100644 --- a/docs/canonical/AGENT_PROTOCOL.md +++ b/docs/canonical/AGENT_PROTOCOL.md @@ -58,10 +58,12 @@ and response contract. Current runtime tranche: +- shipped now: `briefing` +- shipped now: `next` - shipped now: `context ` - shipped now: `claim`, `shape`, `packet`, `ready`, `comment` - shipped now: `act ` for that subset -- planned later in checkpoint 2: `briefing`, `next`, `submissions`, `handoff`, `submit`, `review`, `seal`, `merge` +- planned later in checkpoint 2: `submissions`, `handoff`, `submit`, `review`, `seal`, `merge` ### 3.1 `show` vs `context` diff --git a/src/cli/commands/agent.ts b/src/cli/commands/agent.ts index 030a52e..7c10dd9 100644 --- a/src/cli/commands/agent.ts +++ b/src/cli/commands/agent.ts @@ -16,6 +16,7 @@ import type { AgentActionCandidate, AgentDependencyContext, } from '../../domain/services/AgentRecommender.js'; +import { AgentBriefingService } from '../../domain/services/AgentBriefingService.js'; import type { ReadinessAssessment } from '../../domain/services/ReadinessService.js'; import type { EntityDetail } from '../../domain/models/dashboard.js'; @@ -176,9 +177,152 @@ function renderAgentContext( return lines.join('\n'); } +function renderBriefing(briefing: { + identity: { agentId: string; principalType: string }; + assignments: { quest: { id: string; title: string; status: string }; nextAction: AgentActionCandidate | null }[]; + reviewQueue: { submissionId: string; questTitle: string; status: string }[]; + frontier: { quest: { id: string; title: string; status: string }; nextAction: AgentActionCandidate | null }[]; + alerts: { severity: string; message: string }[]; + graphMeta: { maxTick: number; writerCount: number; tipSha: string } | null; +}): string { + const lines: string[] = []; + lines.push(`${briefing.identity.agentId} [${briefing.identity.principalType}]`); + + lines.push(''); + lines.push(`Assignments (${briefing.assignments.length})`); + if (briefing.assignments.length === 0) { + lines.push(' none'); + } else { + for (const entry of briefing.assignments) { + lines.push(` - ${entry.quest.id} ${entry.quest.title} [${entry.quest.status}]`); + if (entry.nextAction) { + lines.push(` next: ${entry.nextAction.kind}`); + } + } + } + + lines.push(''); + lines.push(`Review Queue (${briefing.reviewQueue.length})`); + if (briefing.reviewQueue.length === 0) { + lines.push(' none'); + } else { + for (const entry of briefing.reviewQueue) { + lines.push(` - ${entry.submissionId} ${entry.questTitle} [${entry.status}]`); + } + } + + lines.push(''); + lines.push(`Frontier (${briefing.frontier.length})`); + if (briefing.frontier.length === 0) { + lines.push(' none'); + } else { + for (const entry of briefing.frontier) { + lines.push(` - ${entry.quest.id} ${entry.quest.title} [${entry.quest.status}]`); + if (entry.nextAction) { + lines.push(` next: ${entry.nextAction.kind}`); + } + } + } + + if (briefing.alerts.length > 0) { + lines.push(''); + lines.push('Alerts'); + for (const alert of briefing.alerts) { + lines.push(` - ${alert.severity}: ${alert.message}`); + } + } + + if (briefing.graphMeta) { + lines.push(''); + lines.push(`Graph: tick=${briefing.graphMeta.maxTick} writers=${briefing.graphMeta.writerCount} tip=${briefing.graphMeta.tipSha}`); + } + + return lines.join('\n'); +} + +function renderNext(candidates: { + kind: string; + targetId: string; + questTitle: string; + source: string; + reason: string; + blockedBy: string[]; +}[]): string { + const lines: string[] = []; + lines.push(`Candidates (${candidates.length})`); + if (candidates.length === 0) { + lines.push(' none'); + return lines.join('\n'); + } + + for (const candidate of candidates) { + lines.push(` - ${candidate.kind} ${candidate.targetId} [${candidate.source}]`); + lines.push(` ${candidate.questTitle}`); + lines.push(` ${candidate.reason}`); + if (candidate.blockedBy.length > 0) { + lines.push(` blockedBy: ${candidate.blockedBy.join(' | ')}`); + } + } + return lines.join('\n'); +} + export function registerAgentCommands(program: Command, ctx: CliContext): void { const withErrorHandler = createErrorHandler(ctx); + program + .command('briefing') + .description('Build a start-of-session agent briefing packet') + .action(withErrorHandler(async () => { + const service = new AgentBriefingService( + ctx.graphPort, + new WarpRoadmapAdapter(ctx.graphPort), + ctx.agentId, + ); + const briefing = await service.buildBriefing(); + + if (ctx.json) { + ctx.jsonOut({ + success: true, + command: 'briefing', + data: { ...briefing }, + }); + return; + } + + ctx.print(renderBriefing(briefing)); + })); + + program + .command('next') + .description('Recommend the next validated actions for this agent') + .option('--limit ', 'Maximum number of action candidates to return', '5') + .action(withErrorHandler(async (opts: { limit: string }) => { + const limit = Number.parseInt(opts.limit, 10); + if (!Number.isFinite(limit) || limit < 1) { + throw new Error(`[INVALID_ARGS] --limit must be a positive integer, got '${opts.limit}'`); + } + + const service = new AgentBriefingService( + ctx.graphPort, + new WarpRoadmapAdapter(ctx.graphPort), + ctx.agentId, + ); + const candidates = await service.next(limit); + + if (ctx.json) { + ctx.jsonOut({ + success: true, + command: 'next', + data: { + candidates, + }, + }); + return; + } + + ctx.print(renderNext(candidates)); + })); + program .command('context ') .description('Build an action-oriented work packet for an entity') diff --git a/src/domain/services/AgentBriefingService.ts b/src/domain/services/AgentBriefingService.ts new file mode 100644 index 0000000..df097fd --- /dev/null +++ b/src/domain/services/AgentBriefingService.ts @@ -0,0 +1,254 @@ +import type { GraphMeta, GraphSnapshot, QuestNode } from '../models/dashboard.js'; +import type { GraphPort } from '../../ports/GraphPort.js'; +import type { RoadmapQueryPort } from '../../ports/RoadmapPort.js'; +import { createGraphContext } from '../../infrastructure/GraphContext.js'; +import { ReadinessService } from './ReadinessService.js'; +import { AgentActionValidator } from './AgentActionService.js'; +import { + AgentRecommender, + type AgentActionCandidate, + type AgentDependencyContext, + type AgentQuestRef, +} from './AgentRecommender.js'; +import { + buildAgentDependencyContext, + toAgentQuestRef, +} from './AgentContextService.js'; + +export interface AgentBriefingIdentity { + agentId: string; + principalType: 'human' | 'agent'; +} + +export interface AgentBriefingAlert { + code: string; + severity: 'info' | 'warning' | 'critical'; + message: string; + relatedIds: string[]; +} + +export interface AgentWorkSummary { + quest: AgentQuestRef; + dependency: AgentDependencyContext; + nextAction: AgentActionCandidate | null; +} + +export interface AgentReviewQueueEntry { + submissionId: string; + questId: string; + questTitle: string; + status: string; + submittedBy: string; + submittedAt: number; + reason: string; +} + +export interface AgentBriefing { + identity: AgentBriefingIdentity; + assignments: AgentWorkSummary[]; + reviewQueue: AgentReviewQueueEntry[]; + frontier: AgentWorkSummary[]; + alerts: AgentBriefingAlert[]; + graphMeta: GraphMeta | null; +} + +export interface AgentNextCandidate extends AgentActionCandidate { + questTitle: string; + questStatus: string; + source: 'assignment' | 'frontier' | 'planning'; +} + +function determineSource( + quest: QuestNode, + dependency: AgentDependencyContext, + agentId: string, +): AgentNextCandidate['source'] { + if (quest.assignedTo === agentId) return 'assignment'; + if (dependency.isFrontier) return 'frontier'; + return 'planning'; +} + +function kindPriority(kind: string): number { + switch (kind) { + case 'claim': + return 0; + case 'ready': + return 1; + case 'packet': + return 2; + default: + return 9; + } +} + +export class AgentBriefingService { + private readonly readiness: ReadinessService; + private readonly recommender: AgentRecommender; + + constructor( + private readonly graphPort: GraphPort, + roadmap: RoadmapQueryPort, + private readonly agentId: string, + ) { + this.readiness = new ReadinessService(roadmap); + this.recommender = new AgentRecommender( + new AgentActionValidator(graphPort, roadmap, agentId), + ); + } + + public async buildBriefing(): Promise { + const snapshot = await this.fetchSnapshot(); + const assignments = await this.buildWorkSummaries( + snapshot.quests.filter((quest) => + quest.assignedTo === this.agentId && + quest.status !== 'DONE' && + quest.status !== 'GRAVEYARD', + ), + snapshot, + ); + + const frontier = await this.buildWorkSummaries( + snapshot.quests.filter((quest) => + quest.status === 'READY' && + quest.assignedTo === undefined, + ), + snapshot, + ); + + const reviewQueue = this.buildReviewQueue(snapshot); + const alerts = this.buildAlerts(assignments, frontier, reviewQueue); + + return { + identity: { + agentId: this.agentId, + principalType: this.agentId.startsWith('human.') ? 'human' : 'agent', + }, + assignments, + reviewQueue, + frontier, + alerts, + graphMeta: snapshot.graphMeta ?? null, + }; + } + + public async next(limit = 5): Promise { + const snapshot = await this.fetchSnapshot(); + const candidates: AgentNextCandidate[] = []; + + for (const quest of snapshot.quests) { + if (quest.status === 'DONE' || quest.status === 'GRAVEYARD') continue; + const readiness = await this.readiness.assess(quest.id, { transition: false }); + const dependency = buildAgentDependencyContext(snapshot, quest); + const source = determineSource(quest, dependency, this.agentId); + const recommendations = await this.recommender.recommendForQuest(quest, readiness, dependency); + + for (const candidate of recommendations) { + candidates.push({ + ...candidate, + questTitle: quest.title, + questStatus: quest.status, + source, + }); + } + } + + candidates.sort((a, b) => + Number(b.allowed) - Number(a.allowed) || + (a.source === 'assignment' ? 0 : a.source === 'frontier' ? 1 : 2) - + (b.source === 'assignment' ? 0 : b.source === 'frontier' ? 1 : 2) || + kindPriority(a.kind) - kindPriority(b.kind) || + b.confidence - a.confidence || + a.targetId.localeCompare(b.targetId) + ); + + return candidates.slice(0, limit); + } + + private async fetchSnapshot(): Promise { + const graphCtx = createGraphContext(this.graphPort); + return graphCtx.fetchSnapshot(); + } + + private async buildWorkSummaries( + quests: QuestNode[], + snapshot: GraphSnapshot, + ): Promise { + const summaries = await Promise.all(quests.map(async (quest) => { + const readiness = await this.readiness.assess(quest.id, { transition: false }); + const dependency = buildAgentDependencyContext(snapshot, quest); + const recommendations = await this.recommender.recommendForQuest(quest, readiness, dependency); + return { + quest: toAgentQuestRef(quest), + dependency, + nextAction: recommendations[0] ?? null, + } satisfies AgentWorkSummary; + })); + + summaries.sort((a, b) => a.quest.id.localeCompare(b.quest.id)); + return summaries; + } + + private buildReviewQueue(snapshot: GraphSnapshot): AgentReviewQueueEntry[] { + const questById = new Map(snapshot.quests.map((quest) => [quest.id, quest] as const)); + const queue = snapshot.submissions + .filter((submission) => + (submission.status === 'OPEN' || submission.status === 'CHANGES_REQUESTED') && + submission.submittedBy !== this.agentId, + ) + .map((submission) => { + const quest = questById.get(submission.questId); + return { + submissionId: submission.id, + questId: submission.questId, + questTitle: quest?.title ?? submission.questId, + status: submission.status, + submittedBy: submission.submittedBy, + submittedAt: submission.submittedAt, + reason: submission.status === 'CHANGES_REQUESTED' + ? 'Needs another review pass after requested changes.' + : 'Open submission awaiting review.', + } satisfies AgentReviewQueueEntry; + }); + + queue.sort((a, b) => b.submittedAt - a.submittedAt || a.submissionId.localeCompare(b.submissionId)); + return queue; + } + + private buildAlerts( + assignments: AgentWorkSummary[], + frontier: AgentWorkSummary[], + reviewQueue: AgentReviewQueueEntry[], + ): AgentBriefingAlert[] { + const alerts: AgentBriefingAlert[] = []; + + const blockedAssignments = assignments.filter((entry) => entry.dependency.blockedBy.length > 0); + if (blockedAssignments.length > 0) { + alerts.push({ + code: 'blocked-assignments', + severity: 'warning', + message: `${blockedAssignments.length} assigned quest(s) are blocked.`, + relatedIds: blockedAssignments.map((entry) => entry.quest.id), + }); + } + + if (reviewQueue.length > 0) { + alerts.push({ + code: 'review-queue', + severity: 'info', + message: `${reviewQueue.length} submission(s) are waiting for review attention.`, + relatedIds: reviewQueue.map((entry) => entry.submissionId), + }); + } + + if (assignments.length === 0 && frontier.length === 0) { + alerts.push({ + code: 'no-active-work', + severity: 'info', + message: 'No active assignments or READY frontier quests were found.', + relatedIds: [], + }); + } + + return alerts; + } +} diff --git a/src/domain/services/AgentContextService.ts b/src/domain/services/AgentContextService.ts index b3f1501..f502e33 100644 --- a/src/domain/services/AgentContextService.ts +++ b/src/domain/services/AgentContextService.ts @@ -20,7 +20,7 @@ export interface AgentContextResult { recommendedActions: AgentActionCandidate[]; } -function toQuestRef(quest: QuestNode): AgentQuestRef { +export function toAgentQuestRef(quest: QuestNode): AgentQuestRef { return { id: quest.id, title: quest.title, @@ -31,7 +31,7 @@ function toQuestRef(quest: QuestNode): AgentQuestRef { }; } -function buildDependencyContext( +export function buildAgentDependencyContext( snapshot: GraphSnapshot, quest: QuestNode, ): AgentDependencyContext { @@ -49,17 +49,17 @@ function buildDependencyContext( const dependsOn = (quest.dependsOn ?? []) .map((id) => questById.get(id)) .filter((entry): entry is QuestNode => Boolean(entry)) - .map(toQuestRef); + .map(toAgentQuestRef); const dependents = snapshot.quests .filter((entry) => (entry.dependsOn ?? []).includes(quest.id)) - .map(toQuestRef) + .map(toAgentQuestRef) .sort((a, b) => a.id.localeCompare(b.id)); const blockedBy = (frontierResult.blockedBy.get(quest.id) ?? []) .map((id) => questById.get(id)) .filter((entry): entry is QuestNode => Boolean(entry)) - .map(toQuestRef); + .map(toAgentQuestRef); const topoIndex = snapshot.sortedTaskIds.indexOf(quest.id); @@ -108,7 +108,7 @@ export class AgentContextService { const quest = detail.questDetail.quest; const readiness = await this.readiness.assess(id, { transition: false }); - const dependency = buildDependencyContext(snapshot, quest); + const dependency = buildAgentDependencyContext(snapshot, quest); const recommendedActions = await this.recommender.recommendForQuest( quest, readiness, diff --git a/src/domain/services/AgentRecommender.ts b/src/domain/services/AgentRecommender.ts index 39b71be..53d8b82 100644 --- a/src/domain/services/AgentRecommender.ts +++ b/src/domain/services/AgentRecommender.ts @@ -49,11 +49,11 @@ export class AgentRecommender { public async recommendForQuest( quest: QuestNode, readiness: ReadinessAssessment | null, - _dependency: AgentDependencyContext, + dependency: AgentDependencyContext, ): Promise { const seeds: CandidateSeed[] = []; - if (quest.status === 'READY') { + if (quest.status === 'READY' && dependency.isFrontier) { seeds.push({ request: { kind: 'claim', diff --git a/test/unit/AgentBriefingService.test.ts b/test/unit/AgentBriefingService.test.ts new file mode 100644 index 0000000..89adac2 --- /dev/null +++ b/test/unit/AgentBriefingService.test.ts @@ -0,0 +1,281 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { Quest } from '../../src/domain/entities/Quest.js'; +import type { GraphPort } from '../../src/ports/GraphPort.js'; +import type { RoadmapQueryPort } from '../../src/ports/RoadmapPort.js'; +import { makeSnapshot, campaign, intent, quest, submission } from '../helpers/snapshot.js'; +import { AgentBriefingService } from '../../src/domain/services/AgentBriefingService.js'; + +const mocks = vi.hoisted(() => ({ + createGraphContext: vi.fn(), +})); + +vi.mock('../../src/infrastructure/GraphContext.js', () => ({ + createGraphContext: (graphPort: unknown) => mocks.createGraphContext(graphPort), +})); + +function makeGraphPort(): GraphPort { + return { + getGraph: vi.fn(), + reset: vi.fn(), + }; +} + +function makeQuestEntity(overrides?: Partial[0]>): Quest { + return new Quest({ + id: 'task:AGT-001', + title: 'Agent native quest', + status: 'READY', + hours: 2, + description: 'Quest is ready for the agent-native protocol.', + type: 'task', + ...overrides, + }); +} + +function makeRoadmap( + quests: Quest[], + outgoingByNode: Record = {}, + incomingByNode: Record = {}, +): RoadmapQueryPort { + const byId = new Map(quests.map((quest) => [quest.id, quest] as const)); + return { + getQuests: vi.fn().mockResolvedValue(quests), + getQuest: vi.fn(async (id: string) => byId.get(id) ?? null), + getOutgoingEdges: vi.fn(async (nodeId: string) => outgoingByNode[nodeId] ?? []), + getIncomingEdges: vi.fn(async (nodeId: string) => incomingByNode[nodeId] ?? []), + }; +} + +describe('AgentBriefingService', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('builds session briefing data from assignments, frontier, review queue, and graph meta', async () => { + const snapshot = makeSnapshot({ + quests: [ + quest({ + id: 'task:AGT-001', + title: 'Assigned ready quest', + status: 'READY', + hours: 2, + description: 'Assigned ready quest', + taskKind: 'delivery', + assignedTo: 'agent.hal', + campaignId: 'campaign:TRACE', + intentId: 'intent:TRACE', + }), + quest({ + id: 'task:AGT-002', + title: 'Unclaimed ready quest', + status: 'READY', + hours: 1, + description: 'Unclaimed ready quest', + taskKind: 'delivery', + campaignId: 'campaign:TRACE', + intentId: 'intent:TRACE', + }), + ], + campaigns: [campaign({ id: 'campaign:TRACE', title: 'Trace Campaign' })], + intents: [intent({ id: 'intent:TRACE', title: 'Trace Intent' })], + submissions: [ + submission({ + id: 'submission:AGT-001', + questId: 'task:AGT-002', + status: 'OPEN', + submittedBy: 'agent.other', + submittedAt: 100, + }), + ], + sortedTaskIds: ['task:AGT-001', 'task:AGT-002'], + graphMeta: { + maxTick: 42, + myTick: 7, + writerCount: 3, + tipSha: 'abc1234', + }, + }); + + mocks.createGraphContext.mockReturnValue({ + fetchSnapshot: vi.fn().mockResolvedValue(snapshot), + fetchEntityDetail: vi.fn(), + filterSnapshot: vi.fn(), + invalidateCache: vi.fn(), + get graph() { + throw new Error('not used in test'); + }, + }); + + const questEntities = [ + makeQuestEntity({ + id: 'task:AGT-001', + title: 'Assigned ready quest', + description: 'Assigned ready quest', + assignedTo: 'agent.hal', + }), + makeQuestEntity({ + id: 'task:AGT-002', + title: 'Unclaimed ready quest', + description: 'Unclaimed ready quest', + }), + ]; + + const service = new AgentBriefingService( + makeGraphPort(), + makeRoadmap( + questEntities, + { + 'task:AGT-001': [ + { type: 'authorized-by', to: 'intent:TRACE' }, + { type: 'belongs-to', to: 'campaign:TRACE' }, + { type: 'implements', to: 'req:AGT-001' }, + ], + 'task:AGT-002': [ + { type: 'authorized-by', to: 'intent:TRACE' }, + { type: 'belongs-to', to: 'campaign:TRACE' }, + { type: 'implements', to: 'req:AGT-002' }, + ], + 'req:AGT-001': [ + { type: 'has-criterion', to: 'criterion:AGT-001' }, + ], + 'req:AGT-002': [ + { type: 'has-criterion', to: 'criterion:AGT-002' }, + ], + }, + { + 'req:AGT-001': [ + { type: 'decomposes-to', from: 'story:AGT-001' }, + ], + 'req:AGT-002': [ + { type: 'decomposes-to', from: 'story:AGT-002' }, + ], + }, + ), + 'agent.hal', + ); + + const briefing = await service.buildBriefing(); + + expect(briefing.identity).toEqual({ + agentId: 'agent.hal', + principalType: 'agent', + }); + expect(briefing.assignments).toHaveLength(1); + expect(briefing.assignments[0]?.quest.id).toBe('task:AGT-001'); + expect(briefing.assignments[0]?.nextAction?.kind).toBe('claim'); + expect(briefing.frontier).toHaveLength(1); + expect(briefing.frontier[0]?.quest.id).toBe('task:AGT-002'); + expect(briefing.reviewQueue).toMatchObject([ + { + submissionId: 'submission:AGT-001', + questId: 'task:AGT-002', + status: 'OPEN', + }, + ]); + expect(briefing.graphMeta?.tipSha).toBe('abc1234'); + expect(briefing.alerts.map((alert) => alert.code)).toContain('review-queue'); + }); + + it('ranks next candidates with current assignments ahead of general planning work', async () => { + const snapshot = makeSnapshot({ + quests: [ + quest({ + id: 'task:AGT-READY', + title: 'Assigned ready quest', + status: 'READY', + hours: 2, + description: 'Assigned ready quest', + taskKind: 'delivery', + assignedTo: 'agent.hal', + campaignId: 'campaign:TRACE', + intentId: 'intent:TRACE', + }), + quest({ + id: 'task:AGT-PLAN', + title: 'Readyable planned quest', + status: 'PLANNED', + hours: 3, + description: 'Readyable planned quest', + taskKind: 'delivery', + campaignId: 'campaign:TRACE', + intentId: 'intent:TRACE', + }), + ], + campaigns: [campaign({ id: 'campaign:TRACE', title: 'Trace Campaign' })], + intents: [intent({ id: 'intent:TRACE', title: 'Trace Intent' })], + sortedTaskIds: ['task:AGT-READY', 'task:AGT-PLAN'], + }); + + mocks.createGraphContext.mockReturnValue({ + fetchSnapshot: vi.fn().mockResolvedValue(snapshot), + fetchEntityDetail: vi.fn(), + filterSnapshot: vi.fn(), + invalidateCache: vi.fn(), + get graph() { + throw new Error('not used in test'); + }, + }); + + const service = new AgentBriefingService( + makeGraphPort(), + makeRoadmap( + [ + makeQuestEntity({ + id: 'task:AGT-READY', + title: 'Assigned ready quest', + description: 'Assigned ready quest', + assignedTo: 'agent.hal', + }), + makeQuestEntity({ + id: 'task:AGT-PLAN', + title: 'Readyable planned quest', + status: 'PLANNED', + hours: 3, + description: 'Readyable planned quest', + }), + ], + { + 'task:AGT-READY': [ + { type: 'authorized-by', to: 'intent:TRACE' }, + { type: 'belongs-to', to: 'campaign:TRACE' }, + { type: 'implements', to: 'req:AGT-READY' }, + ], + 'task:AGT-PLAN': [ + { type: 'authorized-by', to: 'intent:TRACE' }, + { type: 'belongs-to', to: 'campaign:TRACE' }, + { type: 'implements', to: 'req:AGT-PLAN' }, + ], + 'req:AGT-READY': [ + { type: 'has-criterion', to: 'criterion:AGT-READY' }, + ], + 'req:AGT-PLAN': [ + { type: 'has-criterion', to: 'criterion:AGT-PLAN' }, + ], + }, + { + 'req:AGT-READY': [ + { type: 'decomposes-to', from: 'story:AGT-READY' }, + ], + 'req:AGT-PLAN': [ + { type: 'decomposes-to', from: 'story:AGT-PLAN' }, + ], + }, + ), + 'agent.hal', + ); + + const candidates = await service.next(5); + + expect(candidates).toHaveLength(2); + expect(candidates[0]).toMatchObject({ + kind: 'claim', + targetId: 'task:AGT-READY', + source: 'assignment', + }); + expect(candidates[1]).toMatchObject({ + kind: 'ready', + targetId: 'task:AGT-PLAN', + source: 'planning', + }); + }); +}); diff --git a/test/unit/AgentCommands.test.ts b/test/unit/AgentCommands.test.ts index 02729bf..0b3327d 100644 --- a/test/unit/AgentCommands.test.ts +++ b/test/unit/AgentCommands.test.ts @@ -6,6 +6,8 @@ import { registerAgentCommands } from '../../src/cli/commands/agent.js'; const mocks = vi.hoisted(() => ({ execute: vi.fn(), fetchContext: vi.fn(), + buildBriefing: vi.fn(), + nextCandidates: vi.fn(), WarpRoadmapAdapter: vi.fn(), })); @@ -25,6 +27,18 @@ vi.mock('../../src/domain/services/AgentContextService.js', () => ({ }, })); +vi.mock('../../src/domain/services/AgentBriefingService.js', () => ({ + AgentBriefingService: class AgentBriefingService { + buildBriefing() { + return mocks.buildBriefing(); + } + + next(limit: number) { + return mocks.nextCandidates(limit); + } + }, +})); + vi.mock('../../src/infrastructure/adapters/WarpRoadmapAdapter.js', () => ({ WarpRoadmapAdapter: function WarpRoadmapAdapter(graphPort: unknown) { mocks.WarpRoadmapAdapter(graphPort); @@ -55,6 +69,108 @@ describe('agent act command', () => { vi.clearAllMocks(); }); + it('emits a JSON briefing packet', async () => { + mocks.buildBriefing.mockResolvedValue({ + identity: { + agentId: 'agent.hal', + principalType: 'agent', + }, + assignments: [], + reviewQueue: [], + frontier: [], + alerts: [], + graphMeta: { + maxTick: 42, + myTick: 7, + writerCount: 3, + tipSha: 'abc1234', + }, + }); + + const ctx = makeCtx(); + const program = new Command(); + registerAgentCommands(program, ctx); + + await program.parseAsync(['briefing'], { from: 'user' }); + + expect(mocks.buildBriefing).toHaveBeenCalledTimes(1); + expect(ctx.jsonOut).toHaveBeenCalledWith({ + success: true, + command: 'briefing', + data: { + identity: { + agentId: 'agent.hal', + principalType: 'agent', + }, + assignments: [], + reviewQueue: [], + frontier: [], + alerts: [], + graphMeta: { + maxTick: 42, + myTick: 7, + writerCount: 3, + tipSha: 'abc1234', + }, + }, + }); + }); + + it('emits a JSON next-candidate list', async () => { + mocks.nextCandidates.mockResolvedValue([ + { + kind: 'claim', + targetId: 'task:AGT-001', + args: {}, + reason: 'Quest is in READY and can be claimed immediately.', + confidence: 0.98, + requiresHumanApproval: false, + dryRunSummary: 'Move the quest into IN_PROGRESS and assign it to the current agent.', + blockedBy: [], + allowed: true, + underlyingCommand: 'xyph claim task:AGT-001', + sideEffects: ['status -> IN_PROGRESS'], + validationCode: null, + questTitle: 'Agent native quest', + questStatus: 'READY', + source: 'frontier', + }, + ]); + + const ctx = makeCtx(); + const program = new Command(); + registerAgentCommands(program, ctx); + + await program.parseAsync(['next', '--limit', '3'], { from: 'user' }); + + expect(mocks.nextCandidates).toHaveBeenCalledWith(3); + expect(ctx.jsonOut).toHaveBeenCalledWith({ + success: true, + command: 'next', + data: { + candidates: [ + { + kind: 'claim', + targetId: 'task:AGT-001', + args: {}, + reason: 'Quest is in READY and can be claimed immediately.', + confidence: 0.98, + requiresHumanApproval: false, + dryRunSummary: 'Move the quest into IN_PROGRESS and assign it to the current agent.', + blockedBy: [], + allowed: true, + underlyingCommand: 'xyph claim task:AGT-001', + sideEffects: ['status -> IN_PROGRESS'], + validationCode: null, + questTitle: 'Agent native quest', + questStatus: 'READY', + source: 'frontier', + }, + ], + }, + }); + }); + it('emits a JSON context packet for a quest target', async () => { mocks.fetchContext.mockResolvedValue({ detail: { From 9e9f042b741b429199f172f0a4f72b24e37c70be Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 12 Mar 2026 14:08:15 -0700 Subject: [PATCH 07/22] Add agent submissions queue --- docs/canonical/AGENT_PROTOCOL.md | 3 +- src/cli/commands/agent.ts | 89 +++++++ src/domain/services/AgentSubmissionService.ts | 231 ++++++++++++++++++ test/unit/AgentCommands.test.ts | 206 ++++++++++++++++ test/unit/AgentSubmissionService.test.ts | 214 ++++++++++++++++ 5 files changed, 742 insertions(+), 1 deletion(-) create mode 100644 src/domain/services/AgentSubmissionService.ts create mode 100644 test/unit/AgentSubmissionService.test.ts diff --git a/docs/canonical/AGENT_PROTOCOL.md b/docs/canonical/AGENT_PROTOCOL.md index 1a970c5..fca653e 100644 --- a/docs/canonical/AGENT_PROTOCOL.md +++ b/docs/canonical/AGENT_PROTOCOL.md @@ -61,9 +61,10 @@ Current runtime tranche: - shipped now: `briefing` - shipped now: `next` - shipped now: `context ` +- shipped now: `submissions` - shipped now: `claim`, `shape`, `packet`, `ready`, `comment` - shipped now: `act ` for that subset -- planned later in checkpoint 2: `submissions`, `handoff`, `submit`, `review`, `seal`, `merge` +- planned later in checkpoint 2: `handoff`, `submit`, `review`, `seal`, `merge` ### 3.1 `show` vs `context` diff --git a/src/cli/commands/agent.ts b/src/cli/commands/agent.ts index 7c10dd9..1ced13d 100644 --- a/src/cli/commands/agent.ts +++ b/src/cli/commands/agent.ts @@ -17,6 +17,7 @@ import type { AgentDependencyContext, } from '../../domain/services/AgentRecommender.js'; import { AgentBriefingService } from '../../domain/services/AgentBriefingService.js'; +import { AgentSubmissionService } from '../../domain/services/AgentSubmissionService.js'; import type { ReadinessAssessment } from '../../domain/services/ReadinessService.js'; import type { EntityDetail } from '../../domain/models/dashboard.js'; @@ -266,6 +267,69 @@ function renderNext(candidates: { return lines.join('\n'); } +function renderSubmissions(queues: { + counts: { owned: number; reviewable: number; attentionNeeded: number; stale: number }; + staleAfterHours: number; + owned: { + submissionId: string; + questTitle: string; + status: string; + nextStep: { kind: string; targetId: string }; + attentionCodes: string[]; + }[]; + reviewable: { + submissionId: string; + questTitle: string; + status: string; + nextStep: { kind: string; targetId: string }; + attentionCodes: string[]; + }[]; + attentionNeeded: { + submissionId: string; + questTitle: string; + status: string; + nextStep: { kind: string; targetId: string }; + attentionCodes: string[]; + }[]; +}): string { + const renderSection = ( + title: string, + entries: { + submissionId: string; + questTitle: string; + status: string; + nextStep: { kind: string; targetId: string }; + attentionCodes: string[]; + }[], + ): string[] => { + const lines: string[] = []; + lines.push(title); + if (entries.length === 0) { + lines.push(' none'); + return lines; + } + for (const entry of entries) { + lines.push(` - ${entry.submissionId} ${entry.questTitle} [${entry.status}]`); + lines.push(` next: ${entry.nextStep.kind} ${entry.nextStep.targetId}`); + if (entry.attentionCodes.length > 0) { + lines.push(` flags: ${entry.attentionCodes.join(' | ')}`); + } + } + return lines; + }; + + const lines: string[] = []; + lines.push(`Submissions owned=${queues.counts.owned} reviewable=${queues.counts.reviewable} attention=${queues.counts.attentionNeeded} stale=${queues.counts.stale}`); + lines.push(`Stale threshold: ${queues.staleAfterHours}h`); + lines.push(''); + lines.push(...renderSection('Owned', queues.owned)); + lines.push(''); + lines.push(...renderSection('Reviewable', queues.reviewable)); + lines.push(''); + lines.push(...renderSection('Attention Needed', queues.attentionNeeded)); + return lines.join('\n'); +} + export function registerAgentCommands(program: Command, ctx: CliContext): void { const withErrorHandler = createErrorHandler(ctx); @@ -323,6 +387,31 @@ export function registerAgentCommands(program: Command, ctx: CliContext): void { ctx.print(renderNext(candidates)); })); + program + .command('submissions') + .description('Build the agent-facing submission queues') + .option('--limit ', 'Maximum number of entries to return per queue', '10') + .action(withErrorHandler(async (opts: { limit: string }) => { + const limit = Number.parseInt(opts.limit, 10); + if (!Number.isFinite(limit) || limit < 1) { + throw new Error(`[INVALID_ARGS] --limit must be a positive integer, got '${opts.limit}'`); + } + + const service = new AgentSubmissionService(ctx.graphPort, ctx.agentId); + const queues = await service.list(limit); + + if (ctx.json) { + ctx.jsonOut({ + success: true, + command: 'submissions', + data: { ...queues }, + }); + return; + } + + ctx.print(renderSubmissions(queues)); + })); + program .command('context ') .description('Build an action-oriented work packet for an entity') diff --git a/src/domain/services/AgentSubmissionService.ts b/src/domain/services/AgentSubmissionService.ts new file mode 100644 index 0000000..d97c24c --- /dev/null +++ b/src/domain/services/AgentSubmissionService.ts @@ -0,0 +1,231 @@ +import type { + GraphSnapshot, + SubmissionNode, +} from '../models/dashboard.js'; +import type { GraphPort } from '../../ports/GraphPort.js'; +import { createGraphContext } from '../../infrastructure/GraphContext.js'; +import type { + DecisionKind, + ReviewVerdict, + SubmissionStatus, +} from '../entities/Submission.js'; +import { SUBMISSION_STATUS_ORDER } from '../entities/Submission.js'; + +export const AGENT_SUBMISSION_STALE_HOURS = 72; +const STALE_WINDOW_MS = AGENT_SUBMISSION_STALE_HOURS * 60 * 60 * 1000; + +export interface AgentSubmissionNextStep { + kind: 'review' | 'revise' | 'merge' | 'inspect' | 'wait'; + targetId: string; + reason: string; + supportedByActionKernel: boolean; +} + +export interface AgentSubmissionEntry { + submissionId: string; + questId: string; + questTitle: string; + questStatus: string | null; + status: SubmissionStatus; + submittedBy: string; + submittedAt: number; + tipPatchsetId?: string; + headsCount: number; + approvalCount: number; + reviewCount: number; + latestReviewAt: number | null; + latestReviewVerdict: ReviewVerdict | null; + latestDecisionKind: DecisionKind | null; + stale: boolean; + attentionCodes: string[]; + contextId: string; + nextStep: AgentSubmissionNextStep; +} + +export interface AgentSubmissionQueues { + asOf: number; + staleAfterHours: number; + counts: { + owned: number; + reviewable: number; + attentionNeeded: number; + stale: number; + }; + owned: AgentSubmissionEntry[]; + reviewable: AgentSubmissionEntry[]; + attentionNeeded: AgentSubmissionEntry[]; +} + +function isTerminalSubmission(status: SubmissionStatus): boolean { + return status === 'MERGED' || status === 'CLOSED'; +} + +function isReviewableByAgent(submission: SubmissionNode, agentId: string): boolean { + return ( + submission.submittedBy !== agentId && + (submission.status === 'OPEN' || submission.status === 'CHANGES_REQUESTED') + ); +} + +function sortEntries(a: AgentSubmissionEntry, b: AgentSubmissionEntry): number { + return ( + Number(b.stale) - Number(a.stale) || + (SUBMISSION_STATUS_ORDER[a.status] ?? 99) - (SUBMISSION_STATUS_ORDER[b.status] ?? 99) || + b.submittedAt - a.submittedAt || + a.submissionId.localeCompare(b.submissionId) + ); +} + +export class AgentSubmissionService { + constructor( + private readonly graphPort: GraphPort, + private readonly agentId: string, + ) {} + + public async list(limit = 10): Promise { + const graphCtx = createGraphContext(this.graphPort); + const snapshot = await graphCtx.fetchSnapshot(); + const activeSubmissions = snapshot.submissions.filter((entry) => !isTerminalSubmission(entry.status)); + + const entries = activeSubmissions + .map((submission) => this.toEntry(snapshot, submission)) + .sort(sortEntries); + + const owned = entries + .filter((entry) => entry.submittedBy === this.agentId) + .slice(0, limit); + const reviewable = entries + .filter((entry) => isReviewableByAgent({ + id: entry.submissionId, + questId: entry.questId, + status: entry.status, + headsCount: entry.headsCount, + approvalCount: entry.approvalCount, + submittedBy: entry.submittedBy, + submittedAt: entry.submittedAt, + tipPatchsetId: entry.tipPatchsetId, + }, this.agentId)) + .slice(0, limit); + const attentionNeeded = entries + .filter((entry) => entry.attentionCodes.length > 0) + .slice(0, limit); + + return { + asOf: snapshot.asOf, + staleAfterHours: AGENT_SUBMISSION_STALE_HOURS, + counts: { + owned: entries.filter((entry) => entry.submittedBy === this.agentId).length, + reviewable: entries.filter((entry) => isReviewableByAgent({ + id: entry.submissionId, + questId: entry.questId, + status: entry.status, + headsCount: entry.headsCount, + approvalCount: entry.approvalCount, + submittedBy: entry.submittedBy, + submittedAt: entry.submittedAt, + tipPatchsetId: entry.tipPatchsetId, + }, this.agentId)).length, + attentionNeeded: entries.filter((entry) => entry.attentionCodes.length > 0).length, + stale: entries.filter((entry) => entry.stale).length, + }, + owned, + reviewable, + attentionNeeded, + }; + } + + private toEntry(snapshot: GraphSnapshot, submission: SubmissionNode): AgentSubmissionEntry { + const quest = snapshot.quests.find((entry) => entry.id === submission.questId); + const reviews = submission.tipPatchsetId + ? snapshot.reviews.filter((entry) => entry.patchsetId === submission.tipPatchsetId) + : []; + const latestReview = reviews + .slice() + .sort((a, b) => b.reviewedAt - a.reviewedAt || b.id.localeCompare(a.id))[0]; + const latestDecision = snapshot.decisions + .filter((entry) => entry.submissionId === submission.id) + .slice() + .sort((a, b) => b.decidedAt - a.decidedAt || b.id.localeCompare(a.id))[0]; + const stale = snapshot.asOf - submission.submittedAt >= STALE_WINDOW_MS; + const attentionCodes: string[] = []; + + if (stale) { + attentionCodes.push('stale'); + } + if (submission.headsCount > 1) { + attentionCodes.push('forked-heads'); + } + if (submission.submittedBy === this.agentId && submission.status === 'CHANGES_REQUESTED') { + attentionCodes.push('changes-requested'); + } + if (submission.submittedBy === this.agentId && submission.status === 'APPROVED') { + attentionCodes.push('approved-awaiting-merge'); + } + + return { + submissionId: submission.id, + questId: submission.questId, + questTitle: quest?.title ?? submission.questId, + questStatus: quest?.status ?? null, + status: submission.status, + submittedBy: submission.submittedBy, + submittedAt: submission.submittedAt, + tipPatchsetId: submission.tipPatchsetId, + headsCount: submission.headsCount, + approvalCount: submission.approvalCount, + reviewCount: reviews.length, + latestReviewAt: latestReview?.reviewedAt ?? null, + latestReviewVerdict: latestReview?.verdict ?? null, + latestDecisionKind: latestDecision?.kind ?? null, + stale, + attentionCodes, + contextId: submission.questId, + nextStep: this.determineNextStep(submission), + }; + } + + private determineNextStep(submission: SubmissionNode): AgentSubmissionNextStep { + if (isReviewableByAgent(submission, this.agentId)) { + return { + kind: 'review', + targetId: submission.tipPatchsetId ?? submission.id, + reason: 'Review the current tip patchset for this submission.', + supportedByActionKernel: false, + }; + } + + if (submission.submittedBy === this.agentId && submission.status === 'CHANGES_REQUESTED') { + return { + kind: 'revise', + targetId: submission.id, + reason: 'Address requested changes with a new patchset revision.', + supportedByActionKernel: false, + }; + } + + if (submission.submittedBy === this.agentId && submission.status === 'APPROVED') { + return { + kind: 'merge', + targetId: submission.id, + reason: 'Submission is approved and ready for settlement.', + supportedByActionKernel: false, + }; + } + + if (submission.submittedBy === this.agentId) { + return { + kind: 'wait', + targetId: submission.questId, + reason: 'Submission is awaiting external review or follow-up.', + supportedByActionKernel: false, + }; + } + + return { + kind: 'inspect', + targetId: submission.questId, + reason: 'Inspect the quest context before taking a follow-on action.', + supportedByActionKernel: false, + }; + } +} diff --git a/test/unit/AgentCommands.test.ts b/test/unit/AgentCommands.test.ts index 0b3327d..b47656a 100644 --- a/test/unit/AgentCommands.test.ts +++ b/test/unit/AgentCommands.test.ts @@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({ fetchContext: vi.fn(), buildBriefing: vi.fn(), nextCandidates: vi.fn(), + listSubmissions: vi.fn(), WarpRoadmapAdapter: vi.fn(), })); @@ -39,6 +40,14 @@ vi.mock('../../src/domain/services/AgentBriefingService.js', () => ({ }, })); +vi.mock('../../src/domain/services/AgentSubmissionService.js', () => ({ + AgentSubmissionService: class AgentSubmissionService { + list(limit: number) { + return mocks.listSubmissions(limit); + } + }, +})); + vi.mock('../../src/infrastructure/adapters/WarpRoadmapAdapter.js', () => ({ WarpRoadmapAdapter: function WarpRoadmapAdapter(graphPort: unknown) { mocks.WarpRoadmapAdapter(graphPort); @@ -306,6 +315,203 @@ describe('agent act command', () => { }); }); + it('emits a JSON submissions queue packet', async () => { + mocks.listSubmissions.mockResolvedValue({ + asOf: 1_700_000_000_000, + staleAfterHours: 72, + counts: { + owned: 1, + reviewable: 1, + attentionNeeded: 1, + stale: 0, + }, + owned: [ + { + submissionId: 'submission:OWN-001', + questId: 'task:OWN-001', + questTitle: 'Owned quest', + questStatus: 'IN_PROGRESS', + status: 'APPROVED', + submittedBy: 'agent.hal', + submittedAt: 1_700_000_000_000, + tipPatchsetId: 'patchset:OWN-001', + headsCount: 1, + approvalCount: 1, + reviewCount: 1, + latestReviewAt: 1_700_000_000_000, + latestReviewVerdict: 'approve', + latestDecisionKind: null, + stale: false, + attentionCodes: ['approved-awaiting-merge'], + contextId: 'task:OWN-001', + nextStep: { + kind: 'merge', + targetId: 'submission:OWN-001', + reason: 'Submission is approved and ready for settlement.', + supportedByActionKernel: false, + }, + }, + ], + reviewable: [ + { + submissionId: 'submission:REV-001', + questId: 'task:REV-001', + questTitle: 'Reviewable quest', + questStatus: 'READY', + status: 'OPEN', + submittedBy: 'agent.other', + submittedAt: 1_700_000_000_000, + tipPatchsetId: 'patchset:REV-001', + headsCount: 1, + approvalCount: 0, + reviewCount: 0, + latestReviewAt: null, + latestReviewVerdict: null, + latestDecisionKind: null, + stale: false, + attentionCodes: [], + contextId: 'task:REV-001', + nextStep: { + kind: 'review', + targetId: 'patchset:REV-001', + reason: 'Review the current tip patchset for this submission.', + supportedByActionKernel: false, + }, + }, + ], + attentionNeeded: [ + { + submissionId: 'submission:OWN-001', + questId: 'task:OWN-001', + questTitle: 'Owned quest', + questStatus: 'IN_PROGRESS', + status: 'APPROVED', + submittedBy: 'agent.hal', + submittedAt: 1_700_000_000_000, + tipPatchsetId: 'patchset:OWN-001', + headsCount: 1, + approvalCount: 1, + reviewCount: 1, + latestReviewAt: 1_700_000_000_000, + latestReviewVerdict: 'approve', + latestDecisionKind: null, + stale: false, + attentionCodes: ['approved-awaiting-merge'], + contextId: 'task:OWN-001', + nextStep: { + kind: 'merge', + targetId: 'submission:OWN-001', + reason: 'Submission is approved and ready for settlement.', + supportedByActionKernel: false, + }, + }, + ], + }); + + const ctx = makeCtx(); + const program = new Command(); + registerAgentCommands(program, ctx); + + await program.parseAsync(['submissions', '--limit', '4'], { from: 'user' }); + + expect(mocks.listSubmissions).toHaveBeenCalledWith(4); + expect(ctx.jsonOut).toHaveBeenCalledWith({ + success: true, + command: 'submissions', + data: { + asOf: 1_700_000_000_000, + staleAfterHours: 72, + counts: { + owned: 1, + reviewable: 1, + attentionNeeded: 1, + stale: 0, + }, + owned: [ + { + submissionId: 'submission:OWN-001', + questId: 'task:OWN-001', + questTitle: 'Owned quest', + questStatus: 'IN_PROGRESS', + status: 'APPROVED', + submittedBy: 'agent.hal', + submittedAt: 1_700_000_000_000, + tipPatchsetId: 'patchset:OWN-001', + headsCount: 1, + approvalCount: 1, + reviewCount: 1, + latestReviewAt: 1_700_000_000_000, + latestReviewVerdict: 'approve', + latestDecisionKind: null, + stale: false, + attentionCodes: ['approved-awaiting-merge'], + contextId: 'task:OWN-001', + nextStep: { + kind: 'merge', + targetId: 'submission:OWN-001', + reason: 'Submission is approved and ready for settlement.', + supportedByActionKernel: false, + }, + }, + ], + reviewable: [ + { + submissionId: 'submission:REV-001', + questId: 'task:REV-001', + questTitle: 'Reviewable quest', + questStatus: 'READY', + status: 'OPEN', + submittedBy: 'agent.other', + submittedAt: 1_700_000_000_000, + tipPatchsetId: 'patchset:REV-001', + headsCount: 1, + approvalCount: 0, + reviewCount: 0, + latestReviewAt: null, + latestReviewVerdict: null, + latestDecisionKind: null, + stale: false, + attentionCodes: [], + contextId: 'task:REV-001', + nextStep: { + kind: 'review', + targetId: 'patchset:REV-001', + reason: 'Review the current tip patchset for this submission.', + supportedByActionKernel: false, + }, + }, + ], + attentionNeeded: [ + { + submissionId: 'submission:OWN-001', + questId: 'task:OWN-001', + questTitle: 'Owned quest', + questStatus: 'IN_PROGRESS', + status: 'APPROVED', + submittedBy: 'agent.hal', + submittedAt: 1_700_000_000_000, + tipPatchsetId: 'patchset:OWN-001', + headsCount: 1, + approvalCount: 1, + reviewCount: 1, + latestReviewAt: 1_700_000_000_000, + latestReviewVerdict: 'approve', + latestDecisionKind: null, + stale: false, + attentionCodes: ['approved-awaiting-merge'], + contextId: 'task:OWN-001', + nextStep: { + kind: 'merge', + targetId: 'submission:OWN-001', + reason: 'Submission is approved and ready for settlement.', + supportedByActionKernel: false, + }, + }, + ], + }, + }); + }); + it('emits the action-kernel JSON envelope for a dry-run claim', async () => { mocks.execute.mockResolvedValue({ kind: 'claim', diff --git a/test/unit/AgentSubmissionService.test.ts b/test/unit/AgentSubmissionService.test.ts new file mode 100644 index 0000000..7957563 --- /dev/null +++ b/test/unit/AgentSubmissionService.test.ts @@ -0,0 +1,214 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { GraphPort } from '../../src/ports/GraphPort.js'; +import { makeSnapshot, campaign, decision, intent, quest, review, submission } from '../helpers/snapshot.js'; +import { + AGENT_SUBMISSION_STALE_HOURS, + AgentSubmissionService, +} from '../../src/domain/services/AgentSubmissionService.js'; + +const mocks = vi.hoisted(() => ({ + createGraphContext: vi.fn(), +})); + +vi.mock('../../src/infrastructure/GraphContext.js', () => ({ + createGraphContext: (graphPort: unknown) => mocks.createGraphContext(graphPort), +})); + +function makeGraphPort(): GraphPort { + return { + getGraph: vi.fn(), + reset: vi.fn(), + }; +} + +describe('AgentSubmissionService', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('groups owned, reviewable, and attention-needed submission queues', async () => { + const asOf = Date.UTC(2026, 2, 12, 20, 0, 0); + const staleSubmittedAt = asOf - ((AGENT_SUBMISSION_STALE_HOURS + 4) * 60 * 60 * 1000); + + const snapshot = makeSnapshot({ + asOf, + quests: [ + quest({ + id: 'task:OWN-001', + title: 'Owned approved quest', + status: 'IN_PROGRESS', + hours: 3, + }), + quest({ + id: 'task:OWN-002', + title: 'Owned changes quest', + status: 'IN_PROGRESS', + hours: 2, + }), + quest({ + id: 'task:REV-001', + title: 'Reviewable quest', + status: 'READY', + hours: 1, + }), + ], + campaigns: [campaign({ id: 'campaign:TRACE', title: 'Trace' })], + intents: [intent({ id: 'intent:TRACE', title: 'Trace intent' })], + submissions: [ + submission({ + id: 'submission:OWN-001', + questId: 'task:OWN-001', + status: 'APPROVED', + submittedBy: 'agent.hal', + submittedAt: asOf - (2 * 60 * 60 * 1000), + tipPatchsetId: 'patchset:OWN-001', + approvalCount: 1, + }), + submission({ + id: 'submission:OWN-002', + questId: 'task:OWN-002', + status: 'CHANGES_REQUESTED', + submittedBy: 'agent.hal', + submittedAt: staleSubmittedAt, + tipPatchsetId: 'patchset:OWN-002', + headsCount: 2, + }), + submission({ + id: 'submission:REV-001', + questId: 'task:REV-001', + status: 'OPEN', + submittedBy: 'agent.other', + submittedAt: asOf - (60 * 60 * 1000), + tipPatchsetId: 'patchset:REV-001', + }), + submission({ + id: 'submission:TERM-001', + questId: 'task:OWN-001', + status: 'MERGED', + submittedBy: 'agent.hal', + submittedAt: asOf - (30 * 60 * 1000), + tipPatchsetId: 'patchset:TERM-001', + }), + ], + reviews: [ + review({ + id: 'review:OWN-001', + patchsetId: 'patchset:OWN-001', + verdict: 'approve', + reviewedAt: asOf - (90 * 60 * 1000), + }), + review({ + id: 'review:OWN-002', + patchsetId: 'patchset:OWN-002', + verdict: 'request-changes', + reviewedAt: asOf - (80 * 60 * 1000), + }), + ], + decisions: [ + decision({ + id: 'decision:OLD-001', + submissionId: 'submission:TERM-001', + kind: 'merge', + decidedAt: asOf - (20 * 60 * 1000), + }), + ], + }); + + mocks.createGraphContext.mockReturnValue({ + fetchSnapshot: vi.fn().mockResolvedValue(snapshot), + fetchEntityDetail: vi.fn(), + filterSnapshot: vi.fn(), + invalidateCache: vi.fn(), + get graph() { + throw new Error('not used in test'); + }, + }); + + const service = new AgentSubmissionService(makeGraphPort(), 'agent.hal'); + const result = await service.list(10); + + expect(result.counts).toEqual({ + owned: 2, + reviewable: 1, + attentionNeeded: 2, + stale: 1, + }); + expect(result.owned.map((entry) => entry.submissionId)).toEqual([ + 'submission:OWN-002', + 'submission:OWN-001', + ]); + expect(result.reviewable).toMatchObject([ + { + submissionId: 'submission:REV-001', + nextStep: { + kind: 'review', + targetId: 'patchset:REV-001', + }, + }, + ]); + expect(result.attentionNeeded.map((entry) => entry.submissionId)).toEqual([ + 'submission:OWN-002', + 'submission:OWN-001', + ]); + expect(result.attentionNeeded[0]?.attentionCodes).toEqual([ + 'stale', + 'forked-heads', + 'changes-requested', + ]); + expect(result.owned[1]).toMatchObject({ + submissionId: 'submission:OWN-001', + reviewCount: 1, + latestReviewVerdict: 'approve', + nextStep: { + kind: 'merge', + targetId: 'submission:OWN-001', + }, + }); + }); + + it('applies the per-queue limit without changing total counts', async () => { + const asOf = Date.UTC(2026, 2, 12, 20, 0, 0); + const snapshot = makeSnapshot({ + asOf, + quests: [ + quest({ id: 'task:OWN-001', title: 'Owned one', status: 'IN_PROGRESS', hours: 1 }), + quest({ id: 'task:OWN-002', title: 'Owned two', status: 'IN_PROGRESS', hours: 1 }), + ], + submissions: [ + submission({ + id: 'submission:OWN-001', + questId: 'task:OWN-001', + status: 'OPEN', + submittedBy: 'agent.hal', + submittedAt: asOf - 1000, + tipPatchsetId: 'patchset:OWN-001', + }), + submission({ + id: 'submission:OWN-002', + questId: 'task:OWN-002', + status: 'OPEN', + submittedBy: 'agent.hal', + submittedAt: asOf - 2000, + tipPatchsetId: 'patchset:OWN-002', + }), + ], + }); + + mocks.createGraphContext.mockReturnValue({ + fetchSnapshot: vi.fn().mockResolvedValue(snapshot), + fetchEntityDetail: vi.fn(), + filterSnapshot: vi.fn(), + invalidateCache: vi.fn(), + get graph() { + throw new Error('not used in test'); + }, + }); + + const service = new AgentSubmissionService(makeGraphPort(), 'agent.hal'); + const result = await service.list(1); + + expect(result.counts.owned).toBe(2); + expect(result.owned).toHaveLength(1); + expect(result.owned[0]?.submissionId).toBe('submission:OWN-001'); + }); +}); From 32fd6ab02a5560083b118e966959df80f60534f9 Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 12 Mar 2026 14:27:45 -0700 Subject: [PATCH 08/22] Add submit and review to agent action kernel --- docs/canonical/AGENT_PROTOCOL.md | 6 +- src/cli/commands/agent.ts | 13 +- src/domain/services/AgentActionService.ts | 291 +++++++++++++++++- src/domain/services/AgentSubmissionService.ts | 2 +- test/unit/AgentActionService.test.ts | 138 +++++++++ test/unit/AgentCommands.test.ts | 99 +++++- test/unit/AgentSubmissionService.test.ts | 1 + 7 files changed, 541 insertions(+), 9 deletions(-) diff --git a/docs/canonical/AGENT_PROTOCOL.md b/docs/canonical/AGENT_PROTOCOL.md index fca653e..05e0f38 100644 --- a/docs/canonical/AGENT_PROTOCOL.md +++ b/docs/canonical/AGENT_PROTOCOL.md @@ -62,9 +62,9 @@ Current runtime tranche: - shipped now: `next` - shipped now: `context ` - shipped now: `submissions` -- shipped now: `claim`, `shape`, `packet`, `ready`, `comment` +- shipped now: `claim`, `shape`, `packet`, `ready`, `comment`, `submit`, `review` - shipped now: `act ` for that subset -- planned later in checkpoint 2: `handoff`, `submit`, `review`, `seal`, `merge` +- planned later in checkpoint 2: `handoff`, `seal`, `merge` ### 3.1 `show` vs `context` @@ -191,6 +191,8 @@ The current runtime implementation ships the first tranche only: - `packet` - `ready` - `comment` +- `submit` +- `review` ### 5.1 Human-only actions diff --git a/src/cli/commands/agent.ts b/src/cli/commands/agent.ts index 1ced13d..dbe103a 100644 --- a/src/cli/commands/agent.ts +++ b/src/cli/commands/agent.ts @@ -24,6 +24,8 @@ import type { EntityDetail } from '../../domain/models/dashboard.js'; interface ActOptions { dryRun?: boolean; description?: string; + base?: string; + workspace?: string; kind?: string; story?: string; storyTitle?: string; @@ -37,6 +39,7 @@ interface ActOptions { criterion?: string; criterionDescription?: string; verifiable?: boolean; + verdict?: string; message?: string; replyTo?: string; commentId?: string; @@ -45,6 +48,8 @@ interface ActOptions { function buildActionArgs(opts: ActOptions): Record { const args: Record = {}; if (opts.description !== undefined) args['description'] = opts.description.trim(); + if (opts.base !== undefined) args['baseRef'] = opts.base.trim(); + if (opts.workspace !== undefined) args['workspaceRef'] = opts.workspace.trim(); if (opts.kind !== undefined) args['taskKind'] = opts.kind; if (opts.story !== undefined) args['storyId'] = opts.story; if (opts.storyTitle !== undefined) args['storyTitle'] = opts.storyTitle.trim(); @@ -62,6 +67,7 @@ function buildActionArgs(opts: ActOptions): Record { args['criterionDescription'] = opts.criterionDescription.trim(); } if (opts.verifiable === false) args['verifiable'] = false; + if (opts.verdict !== undefined) args['verdict'] = opts.verdict.trim(); if (opts.message !== undefined) args['message'] = opts.message.trim(); if (opts.replyTo !== undefined) args['replyTo'] = opts.replyTo; if (opts.commentId !== undefined) args['commentId'] = opts.commentId; @@ -464,7 +470,9 @@ export function registerAgentCommands(program: Command, ctx: CliContext): void { .command('act ') .description('Execute a validated routine action through the agent action kernel') .option('--dry-run', 'Validate and normalize without mutating graph or workspace') - .option('--description ', 'Quest description for shape') + .option('--description ', 'Description for shape or submit') + .option('--base ', 'Base branch for submit (default: main)') + .option('--workspace ', 'Workspace ref for submit (default: current git branch)') .option('--kind ', `Quest kind for shape (${[...VALID_TASK_KINDS].join(' | ')})`) .option('--story ', 'Story node ID for packet') .option('--story-title ', 'Story title for packet') @@ -478,7 +486,8 @@ export function registerAgentCommands(program: Command, ctx: CliContext): void { .option('--criterion ', 'Criterion node ID for packet') .option('--criterion-description ', 'Criterion description for packet') .option('--no-verifiable', 'Mark a newly created criterion as not independently verifiable') - .option('--message ', 'Comment body for comment') + .option('--verdict ', 'Review verdict for review (approve | request-changes | comment)') + .option('--message ', 'Comment body for comment or review') .option('--reply-to ', 'Reply target for comment') .option('--comment-id ', 'Explicit comment ID for comment') .action(withErrorHandler(async (actionKind: string, targetId: string, opts: ActOptions) => { diff --git a/src/domain/services/AgentActionService.ts b/src/domain/services/AgentActionService.ts index 77958e4..9a2cbc4 100644 --- a/src/domain/services/AgentActionService.ts +++ b/src/domain/services/AgentActionService.ts @@ -10,11 +10,15 @@ import { } from '../entities/Requirement.js'; import { IntakeService } from './IntakeService.js'; import { ReadinessService } from './ReadinessService.js'; +import { SubmissionService } from './SubmissionService.js'; import { createPatchSession } from '../../infrastructure/helpers/createPatchSession.js'; import { WarpIntakeAdapter } from '../../infrastructure/adapters/WarpIntakeAdapter.js'; +import { WarpSubmissionAdapter } from '../../infrastructure/adapters/WarpSubmissionAdapter.js'; +import { GitWorkspaceAdapter } from '../../infrastructure/adapters/GitWorkspaceAdapter.js'; +import type { ReviewVerdict } from '../entities/Submission.js'; export const ROUTINE_AGENT_ACTION_KINDS = [ - 'claim', 'shape', 'packet', 'ready', 'comment', + 'claim', 'shape', 'packet', 'ready', 'comment', 'submit', 'review', ] as const; export const HUMAN_ONLY_AGENT_ACTION_KINDS = [ @@ -103,12 +107,35 @@ interface CommentAction { generatedId: boolean; } +interface SubmitAction { + kind: 'submit'; + targetId: string; + description: string; + baseRef: string; + workspaceRef: string; + headRef?: string; + commitShas?: string[]; + submissionId: string; + patchsetId: string; +} + +interface ReviewAction { + kind: 'review'; + targetId: string; + reviewId: string; + verdict: ReviewVerdict; + comment: string; + submissionId: string; +} + type SupportedNormalizedAction = | ClaimAction | ShapeAction | PacketAction | ReadyAction - | CommentAction; + | CommentAction + | SubmitAction + | ReviewAction; function autoId(prefix: string): string { const ts = Date.now().toString(36).padStart(9, '0'); @@ -184,6 +211,7 @@ function derivePacketId(prefix: 'story:' | 'req:' | 'criterion:', questId: strin export class AgentActionValidator { private readonly intake: IntakeService; private readonly readiness: ReadinessService; + private readonly submissions: SubmissionService; constructor( private readonly graphPort: GraphPort, @@ -192,6 +220,9 @@ export class AgentActionValidator { ) { this.intake = new IntakeService(roadmap); this.readiness = new ReadinessService(roadmap); + this.submissions = new SubmissionService( + new WarpSubmissionAdapter(graphPort, agentId), + ); } public async validate(request: AgentActionRequest): Promise { @@ -223,6 +254,10 @@ export class AgentActionValidator { return this.validateReady(request); case 'comment': return this.validateComment(request); + case 'submit': + return this.validateSubmit(request); + case 'review': + return this.validateReview(request); } } @@ -586,6 +621,194 @@ export class AgentActionValidator { ], ); } + + private async validateSubmit(request: AgentActionRequest): Promise { + if (!request.targetId.startsWith('task:')) { + return failAssessment(request, 'invalid-target', [ + `submit requires a task:* target, got '${request.targetId}'`, + ]); + } + + const description = typeof request.args['description'] === 'string' + ? request.args['description'].trim() + : ''; + if (description.length < 10) { + return failAssessment(request, 'invalid-args', [ + 'submit requires a description of at least 10 characters', + ]); + } + + const baseRef = typeof request.args['baseRef'] === 'string' && request.args['baseRef'].trim().length > 0 + ? request.args['baseRef'].trim() + : 'main'; + + try { + await this.submissions.validateSubmit(request.targetId, this.agentId); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return failAssessment(request, 'precondition-failed', [msg], { + normalizedArgs: { + description, + baseRef, + workspaceRef: typeof request.args['workspaceRef'] === 'string' ? request.args['workspaceRef'] : null, + }, + underlyingCommand: `xyph submit ${request.targetId}`, + sideEffects: [ + 'create submission node', + 'create patchset node', + `submits -> ${request.targetId}`, + 'has-patchset edge', + ], + }); + } + + const workspace = new GitWorkspaceAdapter(process.cwd()); + let workspaceRef: string; + try { + workspaceRef = typeof request.args['workspaceRef'] === 'string' && request.args['workspaceRef'].trim().length > 0 + ? request.args['workspaceRef'].trim() + : await workspace.getWorkspaceRef(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return failAssessment(request, 'workspace-resolution-failed', [ + `Could not resolve workspace ref for submit: ${msg}`, + ], { + normalizedArgs: { + description, + baseRef, + workspaceRef: null, + }, + underlyingCommand: `xyph submit ${request.targetId}`, + sideEffects: [ + 'create submission node', + 'create patchset node', + `submits -> ${request.targetId}`, + 'has-patchset edge', + ], + }); + } + + let headRef: string | undefined; + let commitShas: string[] | undefined; + try { + headRef = await workspace.getHeadCommit(workspaceRef); + commitShas = await workspace.getCommitsSince(baseRef); + } catch { + // Non-fatal: submission packets can omit workspace metadata beyond workspaceRef. + } + + const submissionId = autoId('submission:'); + const patchsetId = autoId('patchset:'); + + return successAssessment( + request, + { + kind: 'submit', + targetId: request.targetId, + description, + baseRef, + workspaceRef, + headRef, + commitShas, + submissionId, + patchsetId, + }, + { + description, + baseRef, + workspaceRef, + headRef: headRef ?? null, + commitShas: commitShas ?? [], + submissionId, + patchsetId, + }, + `xyph submit ${request.targetId}`, + [ + `create ${submissionId}`, + `create ${patchsetId}`, + `submits -> ${request.targetId}`, + `workspace_ref -> ${workspaceRef}`, + ], + ); + } + + private async validateReview(request: AgentActionRequest): Promise { + if (!request.targetId.startsWith('patchset:')) { + return failAssessment(request, 'invalid-target', [ + `review requires a patchset:* target, got '${request.targetId}'`, + ]); + } + + const verdictRaw = typeof request.args['verdict'] === 'string' + ? request.args['verdict'].trim() + : ''; + const validVerdicts: ReviewVerdict[] = ['approve', 'request-changes', 'comment']; + if (!validVerdicts.includes(verdictRaw as ReviewVerdict)) { + return failAssessment(request, 'invalid-args', [ + `verdict must be one of ${validVerdicts.join(', ')}`, + ]); + } + + const comment = typeof request.args['message'] === 'string' + ? request.args['message'].trim() + : ''; + if (comment.length < 1) { + return failAssessment(request, 'invalid-args', [ + 'review requires a non-empty message', + ]); + } + + try { + await this.submissions.validateReview(request.targetId, this.agentId); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return failAssessment(request, 'precondition-failed', [msg], { + normalizedArgs: { + verdict: verdictRaw, + comment, + }, + underlyingCommand: `xyph review ${request.targetId}`, + sideEffects: [ + 'create review node', + `reviews -> ${request.targetId}`, + ], + }); + } + + const adapter = new WarpSubmissionAdapter(this.graphPort, this.agentId); + const submissionId = await adapter.getSubmissionForPatchset(request.targetId); + if (submissionId === null) { + return failAssessment(request, 'not-found', [ + `Patchset ${request.targetId} not found or has no parent submission`, + ]); + } + + const reviewId = autoId('review:'); + const verdict = verdictRaw as ReviewVerdict; + + return successAssessment( + request, + { + kind: 'review', + targetId: request.targetId, + reviewId, + verdict, + comment, + submissionId, + }, + { + reviewId, + verdict, + comment, + submissionId, + }, + `xyph review ${request.targetId} --verdict ${verdict}`, + [ + `create ${reviewId}`, + `reviews -> ${request.targetId}`, + ], + ); + } } export class AgentActionService { @@ -647,6 +870,10 @@ export class AgentActionService { return await this.executeReady(assessment, normalized); case 'comment': return await this.executeComment(assessment, normalized); + case 'submit': + return await this.executeSubmit(assessment, normalized); + case 'review': + return await this.executeReview(assessment, normalized); } } catch (err) { const msg = err instanceof Error ? err.message : String(err); @@ -871,4 +1098,64 @@ export class AgentActionService { }, }; } + + private async executeSubmit( + assessment: ValidatedAssessment, + action: SubmitAction, + ): Promise { + const adapter = new WarpSubmissionAdapter(this.graphPort, this.agentId); + const { patchSha } = await adapter.submit({ + questId: action.targetId, + submissionId: action.submissionId, + patchsetId: action.patchsetId, + patchset: { + workspaceRef: action.workspaceRef, + baseRef: action.baseRef, + headRef: action.headRef, + commitShas: action.commitShas, + description: action.description, + }, + }); + + return { + ...assessment, + result: 'success', + patch: patchSha, + details: { + submissionId: action.submissionId, + patchsetId: action.patchsetId, + questId: action.targetId, + workspaceRef: action.workspaceRef, + baseRef: action.baseRef, + headRef: action.headRef ?? null, + commitCount: action.commitShas?.length ?? 0, + }, + }; + } + + private async executeReview( + assessment: ValidatedAssessment, + action: ReviewAction, + ): Promise { + const adapter = new WarpSubmissionAdapter(this.graphPort, this.agentId); + const { patchSha } = await adapter.review({ + patchsetId: action.targetId, + reviewId: action.reviewId, + verdict: action.verdict, + comment: action.comment, + }); + + return { + ...assessment, + result: 'success', + patch: patchSha, + details: { + reviewId: action.reviewId, + patchsetId: action.targetId, + submissionId: action.submissionId, + verdict: action.verdict, + reviewedBy: this.agentId, + }, + }; + } } diff --git a/src/domain/services/AgentSubmissionService.ts b/src/domain/services/AgentSubmissionService.ts index d97c24c..7d294d2 100644 --- a/src/domain/services/AgentSubmissionService.ts +++ b/src/domain/services/AgentSubmissionService.ts @@ -190,7 +190,7 @@ export class AgentSubmissionService { kind: 'review', targetId: submission.tipPatchsetId ?? submission.id, reason: 'Review the current tip patchset for this submission.', - supportedByActionKernel: false, + supportedByActionKernel: true, }; } diff --git a/test/unit/AgentActionService.test.ts b/test/unit/AgentActionService.test.ts index e4686ee..856a5c0 100644 --- a/test/unit/AgentActionService.test.ts +++ b/test/unit/AgentActionService.test.ts @@ -6,12 +6,64 @@ import { AgentActionService } from '../../src/domain/services/AgentActionService const mocks = vi.hoisted(() => ({ createPatchSession: vi.fn(), + validateSubmit: vi.fn(), + validateReview: vi.fn(), + submit: vi.fn(), + review: vi.fn(), + getSubmissionForPatchset: vi.fn(), + getWorkspaceRef: vi.fn(), + getHeadCommit: vi.fn(), + getCommitsSince: vi.fn(), })); vi.mock('../../src/infrastructure/helpers/createPatchSession.js', () => ({ createPatchSession: (graph: unknown) => mocks.createPatchSession(graph), })); +vi.mock('../../src/domain/services/SubmissionService.js', () => ({ + SubmissionService: class SubmissionService { + validateSubmit(questId: string, actorId: string) { + return mocks.validateSubmit(questId, actorId); + } + + validateReview(patchsetId: string, actorId: string) { + return mocks.validateReview(patchsetId, actorId); + } + }, +})); + +vi.mock('../../src/infrastructure/adapters/WarpSubmissionAdapter.js', () => ({ + WarpSubmissionAdapter: class WarpSubmissionAdapter { + submit(args: unknown) { + return mocks.submit(args); + } + + review(args: unknown) { + return mocks.review(args); + } + + getSubmissionForPatchset(patchsetId: string) { + return mocks.getSubmissionForPatchset(patchsetId); + } + }, +})); + +vi.mock('../../src/infrastructure/adapters/GitWorkspaceAdapter.js', () => ({ + GitWorkspaceAdapter: class GitWorkspaceAdapter { + getWorkspaceRef() { + return mocks.getWorkspaceRef(); + } + + getHeadCommit(ref: string) { + return mocks.getHeadCommit(ref); + } + + getCommitsSince(base: string) { + return mocks.getCommitsSince(base); + } + }, +})); + function makeQuest(overrides?: Partial[0]>): Quest { return new Quest({ id: 'task:AGT-001', @@ -57,6 +109,14 @@ function makePatchSession() { describe('AgentActionService', () => { beforeEach(() => { vi.clearAllMocks(); + mocks.validateSubmit.mockResolvedValue(undefined); + mocks.validateReview.mockResolvedValue(undefined); + mocks.submit.mockResolvedValue({ patchSha: 'patch:submit' }); + mocks.review.mockResolvedValue({ patchSha: 'patch:review' }); + mocks.getSubmissionForPatchset.mockResolvedValue('submission:AGT-001'); + mocks.getWorkspaceRef.mockResolvedValue('feat/agent-action-kernel-v1'); + mocks.getHeadCommit.mockResolvedValue('abc123def456'); + mocks.getCommitsSince.mockResolvedValue(['abc123def456']); }); it('rejects human-only actions with an explicit machine-readable reason', async () => { @@ -208,4 +268,82 @@ describe('AgentActionService', () => { }, }); }); + + it('normalizes submit during dry-run with workspace metadata and generated ids', async () => { + const service = new AgentActionService( + makeGraphPort({}), + makeRoadmap(makeQuest({ status: 'IN_PROGRESS' })), + 'agent.hal', + ); + + const outcome = await service.execute({ + kind: 'submit', + targetId: 'task:AGT-001', + dryRun: true, + args: { + description: 'Submit this quest through the action kernel.', + baseRef: 'main', + }, + }); + + expect(mocks.validateSubmit).toHaveBeenCalledWith('task:AGT-001', 'agent.hal'); + expect(mocks.getWorkspaceRef).toHaveBeenCalledTimes(1); + expect(mocks.getHeadCommit).toHaveBeenCalledWith('feat/agent-action-kernel-v1'); + expect(mocks.getCommitsSince).toHaveBeenCalledWith('main'); + expect(outcome).toMatchObject({ + kind: 'submit', + targetId: 'task:AGT-001', + allowed: true, + result: 'dry-run', + underlyingCommand: 'xyph submit task:AGT-001', + normalizedArgs: { + description: 'Submit this quest through the action kernel.', + baseRef: 'main', + workspaceRef: 'feat/agent-action-kernel-v1', + headRef: 'abc123def456', + commitShas: ['abc123def456'], + }, + }); + expect(typeof outcome.normalizedArgs['submissionId']).toBe('string'); + expect(typeof outcome.normalizedArgs['patchsetId']).toBe('string'); + }); + + it('executes review by writing a review node through the submission adapter', async () => { + const service = new AgentActionService( + makeGraphPort({}), + makeRoadmap(makeQuest()), + 'agent.hal', + ); + + const outcome = await service.execute({ + kind: 'review', + targetId: 'patchset:AGT-001', + args: { + verdict: 'approve', + message: 'Looks good from the action kernel.', + }, + }); + + expect(mocks.validateReview).toHaveBeenCalledWith('patchset:AGT-001', 'agent.hal'); + expect(mocks.getSubmissionForPatchset).toHaveBeenCalledWith('patchset:AGT-001'); + expect(mocks.review).toHaveBeenCalledWith(expect.objectContaining({ + patchsetId: 'patchset:AGT-001', + verdict: 'approve', + comment: 'Looks good from the action kernel.', + })); + expect(outcome).toMatchObject({ + kind: 'review', + targetId: 'patchset:AGT-001', + allowed: true, + result: 'success', + patch: 'patch:review', + details: { + patchsetId: 'patchset:AGT-001', + submissionId: 'submission:AGT-001', + verdict: 'approve', + reviewedBy: 'agent.hal', + }, + }); + expect(typeof outcome.details?.['reviewId']).toBe('string'); + }); }); diff --git a/test/unit/AgentCommands.test.ts b/test/unit/AgentCommands.test.ts index b47656a..a84c753 100644 --- a/test/unit/AgentCommands.test.ts +++ b/test/unit/AgentCommands.test.ts @@ -375,7 +375,7 @@ describe('agent act command', () => { kind: 'review', targetId: 'patchset:REV-001', reason: 'Review the current tip patchset for this submission.', - supportedByActionKernel: false, + supportedByActionKernel: true, }, }, ], @@ -477,7 +477,7 @@ describe('agent act command', () => { kind: 'review', targetId: 'patchset:REV-001', reason: 'Review the current tip patchset for this submission.', - supportedByActionKernel: false, + supportedByActionKernel: true, }, }, ], @@ -644,6 +644,101 @@ describe('agent act command', () => { }); }); + it('maps submit options into normalized action args', async () => { + mocks.execute.mockResolvedValue({ + kind: 'submit', + targetId: 'task:AGT-001', + allowed: true, + dryRun: true, + requiresHumanApproval: false, + validation: { + valid: true, + code: null, + reasons: [], + }, + normalizedArgs: {}, + underlyingCommand: 'xyph submit task:AGT-001', + sideEffects: [], + result: 'dry-run', + patch: null, + details: null, + }); + + const ctx = makeCtx(); + const program = new Command(); + registerAgentCommands(program, ctx); + + await program.parseAsync([ + 'act', + 'submit', + 'task:AGT-001', + '--description', + 'Submit the quest through the action kernel.', + '--base', + 'main', + '--workspace', + 'feat/agent-action-kernel-v1', + '--dry-run', + ], { from: 'user' }); + + expect(mocks.execute).toHaveBeenCalledWith({ + kind: 'submit', + targetId: 'task:AGT-001', + dryRun: true, + args: { + description: 'Submit the quest through the action kernel.', + baseRef: 'main', + workspaceRef: 'feat/agent-action-kernel-v1', + }, + }); + }); + + it('maps review options into normalized action args', async () => { + mocks.execute.mockResolvedValue({ + kind: 'review', + targetId: 'patchset:AGT-001', + allowed: true, + dryRun: true, + requiresHumanApproval: false, + validation: { + valid: true, + code: null, + reasons: [], + }, + normalizedArgs: {}, + underlyingCommand: 'xyph review patchset:AGT-001 --verdict approve', + sideEffects: [], + result: 'dry-run', + patch: null, + details: null, + }); + + const ctx = makeCtx(); + const program = new Command(); + registerAgentCommands(program, ctx); + + await program.parseAsync([ + 'act', + 'review', + 'patchset:AGT-001', + '--verdict', + 'approve', + '--message', + 'Looks good from the action kernel.', + '--dry-run', + ], { from: 'user' }); + + expect(mocks.execute).toHaveBeenCalledWith({ + kind: 'review', + targetId: 'patchset:AGT-001', + dryRun: true, + args: { + verdict: 'approve', + message: 'Looks good from the action kernel.', + }, + }); + }); + it('routes rejected actions through the JSON error envelope', async () => { const rejected = { kind: 'promote', diff --git a/test/unit/AgentSubmissionService.test.ts b/test/unit/AgentSubmissionService.test.ts index 7957563..5d6b1b5 100644 --- a/test/unit/AgentSubmissionService.test.ts +++ b/test/unit/AgentSubmissionService.test.ts @@ -143,6 +143,7 @@ describe('AgentSubmissionService', () => { nextStep: { kind: 'review', targetId: 'patchset:REV-001', + supportedByActionKernel: true, }, }, ]); From c2c99692dfacee108526fe7ca6edd6ecf555f5f2 Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 12 Mar 2026 17:49:39 -0700 Subject: [PATCH 09/22] Require independent review for submissions --- docs/canonical/AGENT_PROTOCOL.md | 6 ++- src/domain/entities/Submission.ts | 16 +++++++ src/domain/services/SubmissionService.ts | 16 ++++++- src/infrastructure/GraphContext.ts | 6 ++- .../adapters/WarpSubmissionAdapter.ts | 8 ++++ .../GraphContextEntityDetail.test.ts | 48 +++++++++++++++++++ test/unit/SubmissionService.test.ts | 35 ++++++++++++++ 7 files changed, 131 insertions(+), 4 deletions(-) diff --git a/docs/canonical/AGENT_PROTOCOL.md b/docs/canonical/AGENT_PROTOCOL.md index 05e0f38..7c14dca 100644 --- a/docs/canonical/AGENT_PROTOCOL.md +++ b/docs/canonical/AGENT_PROTOCOL.md @@ -37,7 +37,11 @@ second workflow model and not an informal wrapper around raw commands. Agents may perform routine operations when XYPH gates pass. Sovereignty, scope control, and constitutionally sensitive changes remain human-bound. -5. **Graph-native collaboration** +5. **Independent review** + A submitter's own review does not satisfy approval policy. Settlement + requires approval from a different principal on the current tip. + +6. **Graph-native collaboration** Handoffs, notes, comments, and quest-linked discussion live in the WARP graph as nodes with queryable metadata and attached content blobs. diff --git a/src/domain/entities/Submission.ts b/src/domain/entities/Submission.ts index f1ba0bf..0d68b64 100644 --- a/src/domain/entities/Submission.ts +++ b/src/domain/entities/Submission.ts @@ -249,6 +249,22 @@ export function computeEffectiveVerdicts( return effective; } +/** + * Removes verdicts from a principal who must not count toward independent review. + * Used to ensure the submitter's own verdicts do not satisfy approval policy. + */ +export function filterIndependentVerdicts( + effectiveVerdicts: Map, + excludedReviewer: string, +): Map { + const filtered = new Map(); + for (const [reviewer, verdict] of effectiveVerdicts) { + if (reviewer === excludedReviewer) continue; + filtered.set(reviewer, verdict); + } + return filtered; +} + function reviewTieBreaker(a: ReviewRef, b: ReviewRef): number { // Sort descending: negative means a wins if (a.reviewedAt !== b.reviewedAt) return b.reviewedAt - a.reviewedAt; diff --git a/src/domain/services/SubmissionService.ts b/src/domain/services/SubmissionService.ts index 2479542..d32a7d3 100644 --- a/src/domain/services/SubmissionService.ts +++ b/src/domain/services/SubmissionService.ts @@ -12,6 +12,7 @@ import { computeStatus, computeTipPatchset, computeEffectiveVerdicts, + filterIndependentVerdicts, type PatchsetRef, type ReviewRef, type DecisionProps, @@ -43,6 +44,9 @@ export interface SubmissionReadModel { /** Returns decisions for a submission. */ getDecisionsForSubmission(submissionId: string): Promise; + + /** Returns the principal who opened the submission. */ + getSubmissionSubmittedBy(submissionId: string): Promise; } // --------------------------------------------------------------------------- @@ -62,15 +66,19 @@ export class SubmissionService { async getSubmissionStatus(submissionId: string): Promise { const patchsetRefs = await this.read.getPatchsetRefs(submissionId); const { tip } = computeTipPatchset(patchsetRefs); + const submittedBy = await this.read.getSubmissionSubmittedBy(submissionId); let effectiveVerdicts = new Map(); if (tip) { const reviews = await this.read.getReviewsForPatchset(tip.id); effectiveVerdicts = computeEffectiveVerdicts(reviews); } + const independentVerdicts = submittedBy + ? filterIndependentVerdicts(effectiveVerdicts, submittedBy) + : effectiveVerdicts; const decisions = await this.read.getDecisionsForSubmission(submissionId); - return computeStatus({ decisions, effectiveVerdicts }); + return computeStatus({ decisions, effectiveVerdicts: independentVerdicts }); } /** @@ -156,6 +164,12 @@ export class SubmissionService { if (submissionId === null) { throw new Error(`[NOT_FOUND] Patchset ${patchsetId} not found or has no parent submission`); } + const submittedBy = await this.read.getSubmissionSubmittedBy(submissionId); + if (submittedBy === actorId) { + throw new Error( + `[FORBIDDEN] review requires an independent reviewer, submission ${submissionId} was submitted by ${submittedBy}` + ); + } const status = await this.getSubmissionStatus(submissionId); if (this.isTerminal(status)) { diff --git a/src/infrastructure/GraphContext.ts b/src/infrastructure/GraphContext.ts index 10a5e2c..2c0a38c 100644 --- a/src/infrastructure/GraphContext.ts +++ b/src/infrastructure/GraphContext.ts @@ -25,6 +25,7 @@ import { computeStatus, computeTipPatchset, computeEffectiveVerdicts, + filterIndependentVerdicts, type PatchsetRef, type ReviewRef, type DecisionProps, @@ -1576,12 +1577,13 @@ class GraphContextImpl implements GraphContext { if (tip) { effectiveVerdicts = computeEffectiveVerdicts(reviewsByPatchset.get(tip.id) ?? []); } + const independentVerdicts = filterIndependentVerdicts(effectiveVerdicts, submittedBy); const subDecisions = decisionsBySubmission.get(n.id) ?? []; - const status = computeStatus({ decisions: subDecisions, effectiveVerdicts }); + const status = computeStatus({ decisions: subDecisions, effectiveVerdicts: independentVerdicts }); let approvalCount = 0; - for (const v of effectiveVerdicts.values()) { + for (const v of independentVerdicts.values()) { if (v === 'approve') approvalCount++; } diff --git a/src/infrastructure/adapters/WarpSubmissionAdapter.ts b/src/infrastructure/adapters/WarpSubmissionAdapter.ts index 8a81373..bade3e2 100644 --- a/src/infrastructure/adapters/WarpSubmissionAdapter.ts +++ b/src/infrastructure/adapters/WarpSubmissionAdapter.ts @@ -182,6 +182,14 @@ export class WarpSubmissionAdapter implements SubmissionPort, SubmissionReadMode return typeof questId === 'string' ? questId : null; } + public async getSubmissionSubmittedBy(submissionId: string): Promise { + const graph = await this.graphPort.getGraph(); + const props = await graph.getNodeProps(submissionId); + if (!props) return null; + const submittedBy = props['submitted_by']; + return typeof submittedBy === 'string' ? submittedBy : null; + } + public async getOpenSubmissionsForQuest(questId: string): Promise { const graph = await this.graphPort.getGraph(); const submissionNeighbors = toNeighborEntries( diff --git a/test/integration/GraphContextEntityDetail.test.ts b/test/integration/GraphContextEntityDetail.test.ts index ac7085f..f307731 100644 --- a/test/integration/GraphContextEntityDetail.test.ts +++ b/test/integration/GraphContextEntityDetail.test.ts @@ -211,4 +211,52 @@ describe('GraphContext entity detail integration', () => { 'comment:SHOW-2', ])); }); + + it('does not count the submitter as an independent approver in snapshot submission status', { timeout: 30_000 }, async () => { + const graph = await graphPort.getGraph(); + + await graph.patch((p) => { + p.addNode('task:SELF-001') + .setProperty('task:SELF-001', 'title', 'Self approval should not count') + .setProperty('task:SELF-001', 'status', 'IN_PROGRESS') + .setProperty('task:SELF-001', 'hours', 2) + .setProperty('task:SELF-001', 'type', 'task'); + + p.addNode('submission:SELF-001') + .setProperty('submission:SELF-001', 'type', 'submission') + .setProperty('submission:SELF-001', 'quest_id', 'task:SELF-001') + .setProperty('submission:SELF-001', 'submitted_by', 'agent.submitter') + .setProperty('submission:SELF-001', 'submitted_at', 1_700_100_000_100) + .addEdge('submission:SELF-001', 'task:SELF-001', 'submits'); + + p.addNode('patchset:SELF-001') + .setProperty('patchset:SELF-001', 'type', 'patchset') + .setProperty('patchset:SELF-001', 'workspace_ref', 'feat/self-review') + .setProperty('patchset:SELF-001', 'description', 'Self-approval should not make this approved.') + .setProperty('patchset:SELF-001', 'authored_by', 'agent.submitter') + .setProperty('patchset:SELF-001', 'authored_at', 1_700_100_000_101) + .addEdge('patchset:SELF-001', 'submission:SELF-001', 'has-patchset'); + + p.addNode('review:SELF-001') + .setProperty('review:SELF-001', 'type', 'review') + .setProperty('review:SELF-001', 'verdict', 'approve') + .setProperty('review:SELF-001', 'comment', 'I approve my own work.') + .setProperty('review:SELF-001', 'reviewed_by', 'agent.submitter') + .setProperty('review:SELF-001', 'reviewed_at', 1_700_100_000_102) + .addEdge('review:SELF-001', 'patchset:SELF-001', 'reviews'); + }); + + const ctx = createGraphContext(graphPort); + const snapshot = await ctx.fetchSnapshot(); + const submission = snapshot.submissions.find((entry) => entry.id === 'submission:SELF-001'); + + expect(submission).toBeDefined(); + expect(submission).toMatchObject({ + id: 'submission:SELF-001', + status: 'OPEN', + approvalCount: 0, + tipPatchsetId: 'patchset:SELF-001', + submittedBy: 'agent.submitter', + }); + }); }); diff --git a/test/unit/SubmissionService.test.ts b/test/unit/SubmissionService.test.ts index bb77111..0522c0b 100644 --- a/test/unit/SubmissionService.test.ts +++ b/test/unit/SubmissionService.test.ts @@ -18,6 +18,7 @@ function makeReadModel(overrides: Partial = {}): Submission getSubmissionForPatchset: vi.fn().mockResolvedValue('submission:S1'), getReviewsForPatchset: vi.fn().mockResolvedValue([]), getDecisionsForSubmission: vi.fn().mockResolvedValue([]), + getSubmissionSubmittedBy: vi.fn().mockResolvedValue('agent.submitter'), ...overrides, }; } @@ -178,6 +179,17 @@ describe('SubmissionService.validateReview', () => { svc.validateReview('patchset:S1:P1', 'human.alice'), ).rejects.toThrow('[INVALID_FROM]'); }); + + it('throws [FORBIDDEN] when submitter tries to review their own submission', async () => { + const svc = new SubmissionService( + makeReadModel({ + getSubmissionSubmittedBy: vi.fn().mockResolvedValue('agent.submitter'), + }), + ); + await expect( + svc.validateReview('patchset:S1:P1', 'agent.submitter'), + ).rejects.toThrow('[FORBIDDEN]'); + }); }); // --------------------------------------------------------------------------- @@ -223,6 +235,29 @@ describe('SubmissionService.validateMerge', () => { ).rejects.toThrow('[INVALID_FROM]'); }); + it('throws [INVALID_FROM] when only the submitter approved the current tip', async () => { + const selfApprove: ReviewRef = { + id: 'r1', + verdict: 'approve', + reviewedBy: 'agent.submitter', + reviewedAt: 200, + }; + const tipPatchset: PatchsetRef = { + id: 'patchset:S1:P1', + authoredAt: 100, + }; + const svc = new SubmissionService( + makeReadModel({ + getPatchsetRefs: vi.fn().mockResolvedValue([tipPatchset]), + getReviewsForPatchset: vi.fn().mockResolvedValue([selfApprove]), + getSubmissionSubmittedBy: vi.fn().mockResolvedValue('agent.submitter'), + }), + ); + await expect( + svc.validateMerge('submission:S1', 'human.james'), + ).rejects.toThrow('[INVALID_FROM]'); + }); + it('throws [AMBIGUOUS_TIP] when multiple heads exist', async () => { const approveReview: ReviewRef = { id: 'r1', From e5294f5aa6de73197222afddd681e63596510d26 Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 12 Mar 2026 17:52:33 -0700 Subject: [PATCH 10/22] Surface review discussion in quest detail --- docs/canonical/AGENT_PROTOCOL.md | 3 + src/infrastructure/GraphContext.ts | 10 ++- .../GraphContextEntityDetail.test.ts | 75 ++++++++++++++++++- 3 files changed, 86 insertions(+), 2 deletions(-) diff --git a/docs/canonical/AGENT_PROTOCOL.md b/docs/canonical/AGENT_PROTOCOL.md index 7c14dca..b89f151 100644 --- a/docs/canonical/AGENT_PROTOCOL.md +++ b/docs/canonical/AGENT_PROTOCOL.md @@ -44,6 +44,9 @@ second workflow model and not an informal wrapper around raw commands. 6. **Graph-native collaboration** Handoffs, notes, comments, and quest-linked discussion live in the WARP graph as nodes with queryable metadata and attached content blobs. + Review discussion should attach to `patchset:*` and `review:*` nodes so the + quest issue-page projection can render change-specific threads without + deferring to GitHub. ## 3. Command Set diff --git a/src/infrastructure/GraphContext.ts b/src/infrastructure/GraphContext.ts index 2c0a38c..41903f2 100644 --- a/src/infrastructure/GraphContext.ts +++ b/src/infrastructure/GraphContext.ts @@ -1013,6 +1013,7 @@ class GraphContextImpl implements GraphContext { ? await this.findPatchsetIdsForSubmission(graph, submission.id) : new Set(); const reviews = snapshot.reviews.filter((entry) => patchsetIds.has(entry.patchsetId)); + const reviewIds = new Set(reviews.map((entry) => entry.id)); const decisions = submission ? snapshot.decisions.filter((entry) => entry.submissionId === submission.id) : []; @@ -1022,6 +1023,8 @@ class GraphContextImpl implements GraphContext { ...requirements.map((entry) => entry.id), ...stories.map((entry) => entry.id), ...criteria.map((entry) => entry.id), + ...patchsetIds, + ...reviewIds, ]); if (campaign) relevantIds.add(campaign.id); if (intent) relevantIds.add(intent.id); @@ -1452,11 +1455,16 @@ class GraphContextImpl implements GraphContext { }); } for (const comment of comments) { + const targetLabel = comment.replyToId + ? `Reply to ${comment.replyToId}` + : comment.targetId + ? `Comment on ${comment.targetId}` + : 'Comment'; entries.push({ id: comment.id, at: comment.authoredAt, kind: 'comment', - title: comment.replyToId ? `Reply to ${comment.replyToId}` : 'Comment', + title: targetLabel, actor: comment.authoredBy, relatedId: comment.targetId ?? comment.replyToId, }); diff --git a/test/integration/GraphContextEntityDetail.test.ts b/test/integration/GraphContextEntityDetail.test.ts index f307731..f7f2d58 100644 --- a/test/integration/GraphContextEntityDetail.test.ts +++ b/test/integration/GraphContextEntityDetail.test.ts @@ -140,6 +140,52 @@ describe('GraphContext entity detail integration', () => { await reply.attachContent('comment:SHOW-2', 'Agreed. The JSON shape should stabilize first.'); await reply.commit(); + await graph.patch((p) => { + p.addNode('submission:SHOW') + .setProperty('submission:SHOW', 'type', 'submission') + .setProperty('submission:SHOW', 'quest_id', 'task:SHOW-001') + .setProperty('submission:SHOW', 'submitted_by', 'agent.builder') + .setProperty('submission:SHOW', 'submitted_at', 1_700_100_000_009) + .addEdge('submission:SHOW', 'task:SHOW-001', 'submits'); + + p.addNode('patchset:SHOW') + .setProperty('patchset:SHOW', 'type', 'patchset') + .setProperty('patchset:SHOW', 'workspace_ref', 'feat/show-detail') + .setProperty('patchset:SHOW', 'description', 'Quest detail patchset for review discussion.') + .setProperty('patchset:SHOW', 'authored_by', 'agent.builder') + .setProperty('patchset:SHOW', 'authored_at', 1_700_100_000_010) + .addEdge('patchset:SHOW', 'submission:SHOW', 'has-patchset'); + + p.addNode('review:SHOW') + .setProperty('review:SHOW', 'type', 'review') + .setProperty('review:SHOW', 'verdict', 'comment') + .setProperty('review:SHOW', 'comment', 'Initial review comment') + .setProperty('review:SHOW', 'reviewed_by', 'human.reviewer') + .setProperty('review:SHOW', 'reviewed_at', 1_700_100_000_011) + .addEdge('review:SHOW', 'patchset:SHOW', 'reviews'); + }); + + const patchsetComment = await createPatchSession(graph); + patchsetComment + .addNode('comment:SHOW-3') + .setProperty('comment:SHOW-3', 'type', 'comment') + .setProperty('comment:SHOW-3', 'authored_by', 'human.reviewer') + .setProperty('comment:SHOW-3', 'authored_at', 1_700_100_000_012) + .addEdge('comment:SHOW-3', 'patchset:SHOW', 'comments-on'); + await patchsetComment.attachContent('comment:SHOW-3', 'Please explain the traceability rollup in this patchset.'); + await patchsetComment.commit(); + + const reviewReply = await createPatchSession(graph); + reviewReply + .addNode('comment:SHOW-4') + .setProperty('comment:SHOW-4', 'type', 'comment') + .setProperty('comment:SHOW-4', 'authored_by', 'agent.builder') + .setProperty('comment:SHOW-4', 'authored_at', 1_700_100_000_013) + .addEdge('comment:SHOW-4', 'review:SHOW', 'comments-on') + .addEdge('comment:SHOW-4', 'comment:SHOW-3', 'replies-to'); + await reviewReply.attachContent('comment:SHOW-4', 'Added a clearer explanation and updated the quest timeline labels.'); + await reviewReply.commit(); + const ctx = createGraphContext(graphPort); const detail = await ctx.fetchEntityDetail('task:SHOW-001'); @@ -191,7 +237,12 @@ describe('GraphContext entity detail integration', () => { targetIds: ['req:SHOW'], }); - expect(questDetail.comments.map((entry) => entry.id)).toEqual(['comment:SHOW-1', 'comment:SHOW-2']); + expect(questDetail.comments.map((entry) => entry.id)).toEqual([ + 'comment:SHOW-1', + 'comment:SHOW-2', + 'comment:SHOW-3', + 'comment:SHOW-4', + ]); expect(questDetail.comments.find((entry) => entry.id === 'comment:SHOW-1')).toMatchObject({ targetId: 'task:SHOW-001', replyIds: ['comment:SHOW-2'], @@ -201,15 +252,37 @@ describe('GraphContext entity detail integration', () => { replyToId: 'comment:SHOW-1', body: 'Agreed. The JSON shape should stabilize first.', }); + expect(questDetail.comments.find((entry) => entry.id === 'comment:SHOW-3')).toMatchObject({ + targetId: 'patchset:SHOW', + replyIds: ['comment:SHOW-4'], + body: 'Please explain the traceability rollup in this patchset.', + }); + expect(questDetail.comments.find((entry) => entry.id === 'comment:SHOW-4')).toMatchObject({ + targetId: 'review:SHOW', + replyToId: 'comment:SHOW-3', + body: 'Added a clearer explanation and updated the quest timeline labels.', + }); expect(questDetail.timeline.map((entry) => entry.id)).toEqual(expect.arrayContaining([ 'task:SHOW-001:ready', 'evidence:SHOW', + 'submission:SHOW', + 'review:SHOW', 'note:SHOW-v1', 'note:SHOW-v2', 'comment:SHOW-1', 'comment:SHOW-2', + 'comment:SHOW-3', + 'comment:SHOW-4', ])); + expect(questDetail.timeline.find((entry) => entry.id === 'comment:SHOW-3')).toMatchObject({ + title: 'Comment on patchset:SHOW', + relatedId: 'patchset:SHOW', + }); + expect(questDetail.timeline.find((entry) => entry.id === 'comment:SHOW-4')).toMatchObject({ + title: 'Reply to comment:SHOW-3', + relatedId: 'review:SHOW', + }); }); it('does not count the submitter as an independent approver in snapshot submission status', { timeout: 30_000 }, async () => { From b74b9efedafe0f0a257ac837b6aebca80142e6f8 Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 12 Mar 2026 18:38:13 -0700 Subject: [PATCH 11/22] Add graph-native agent handoffs --- docs/canonical/AGENT_PROTOCOL.md | 9 +- src/cli/commands/agent.ts | 78 +++++++++++ src/cli/commands/show.ts | 5 +- src/domain/models/dashboard.ts | 1 + src/domain/services/AgentActionService.ts | 139 +++++++++++++++++++- src/domain/services/AgentBriefingService.ts | 65 +++++++++ src/infrastructure/GraphContext.ts | 10 +- test/unit/AgentActionService.test.ts | 85 ++++++++++++ test/unit/AgentBriefingService.test.ts | 42 +++++- test/unit/AgentCommands.test.ts | 119 +++++++++++++++++ 10 files changed, 543 insertions(+), 10 deletions(-) diff --git a/docs/canonical/AGENT_PROTOCOL.md b/docs/canonical/AGENT_PROTOCOL.md index b89f151..dcfe653 100644 --- a/docs/canonical/AGENT_PROTOCOL.md +++ b/docs/canonical/AGENT_PROTOCOL.md @@ -69,9 +69,10 @@ Current runtime tranche: - shipped now: `next` - shipped now: `context ` - shipped now: `submissions` -- shipped now: `claim`, `shape`, `packet`, `ready`, `comment`, `submit`, `review` +- shipped now: `handoff` +- shipped now: `claim`, `shape`, `packet`, `ready`, `comment`, `submit`, `review`, `handoff` - shipped now: `act ` for that subset -- planned later in checkpoint 2: `handoff`, `seal`, `merge` +- planned later in checkpoint 2: `seal`, `merge` ### 3.1 `show` vs `context` @@ -105,6 +106,9 @@ includes: Each frontier or review entry should already contain an executable next step or an action candidate reference. +The runtime may also include `recentHandoffs` so agents can resume from their +own recent closeout notes without hunting through raw quest history. + ### 4.2 `next --json` `next` returns structured action candidates, not prose-only recommendations. @@ -200,6 +204,7 @@ The current runtime implementation ships the first tranche only: - `comment` - `submit` - `review` +- `handoff` ### 5.1 Human-only actions diff --git a/src/cli/commands/agent.ts b/src/cli/commands/agent.ts index dbe103a..f7170df 100644 --- a/src/cli/commands/agent.ts +++ b/src/cli/commands/agent.ts @@ -24,6 +24,7 @@ import type { EntityDetail } from '../../domain/models/dashboard.js'; interface ActOptions { dryRun?: boolean; description?: string; + title?: string; base?: string; workspace?: string; kind?: string; @@ -43,11 +44,13 @@ interface ActOptions { message?: string; replyTo?: string; commentId?: string; + related?: string[]; } function buildActionArgs(opts: ActOptions): Record { const args: Record = {}; if (opts.description !== undefined) args['description'] = opts.description.trim(); + if (opts.title !== undefined) args['title'] = opts.title.trim(); if (opts.base !== undefined) args['baseRef'] = opts.base.trim(); if (opts.workspace !== undefined) args['workspaceRef'] = opts.workspace.trim(); if (opts.kind !== undefined) args['taskKind'] = opts.kind; @@ -71,6 +74,11 @@ function buildActionArgs(opts: ActOptions): Record { if (opts.message !== undefined) args['message'] = opts.message.trim(); if (opts.replyTo !== undefined) args['replyTo'] = opts.replyTo; if (opts.commentId !== undefined) args['commentId'] = opts.commentId; + if (opts.related !== undefined) { + args['relatedIds'] = opts.related + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + } return args; } @@ -189,6 +197,7 @@ function renderBriefing(briefing: { assignments: { quest: { id: string; title: string; status: string }; nextAction: AgentActionCandidate | null }[]; reviewQueue: { submissionId: string; questTitle: string; status: string }[]; frontier: { quest: { id: string; title: string; status: string }; nextAction: AgentActionCandidate | null }[]; + recentHandoffs: { noteId: string; title: string; authoredAt: number; relatedIds: string[] }[]; alerts: { severity: string; message: string }[]; graphMeta: { maxTick: number; writerCount: number; tipSha: string } | null; }): string { @@ -231,6 +240,20 @@ function renderBriefing(briefing: { } } + lines.push(''); + lines.push(`Recent Handoffs (${briefing.recentHandoffs.length})`); + if (briefing.recentHandoffs.length === 0) { + lines.push(' none'); + } else { + for (const entry of briefing.recentHandoffs) { + lines.push(` - ${entry.noteId} ${entry.title}`); + lines.push(` at: ${new Date(entry.authoredAt).toISOString()}`); + if (entry.relatedIds.length > 0) { + lines.push(` related: ${entry.relatedIds.join(', ')}`); + } + } + } + if (briefing.alerts.length > 0) { lines.push(''); lines.push('Alerts'); @@ -471,6 +494,7 @@ export function registerAgentCommands(program: Command, ctx: CliContext): void { .description('Execute a validated routine action through the agent action kernel') .option('--dry-run', 'Validate and normalize without mutating graph or workspace') .option('--description ', 'Description for shape or submit') + .option('--title ', 'Title for handoff') .option('--base ', 'Base branch for submit (default: main)') .option('--workspace ', 'Workspace ref for submit (default: current git branch)') .option('--kind ', `Quest kind for shape (${[...VALID_TASK_KINDS].join(' | ')})`) @@ -490,6 +514,7 @@ export function registerAgentCommands(program: Command, ctx: CliContext): void { .option('--message ', 'Comment body for comment or review') .option('--reply-to ', 'Reply target for comment') .option('--comment-id ', 'Explicit comment ID for comment') + .option('--related ', 'Additional related IDs for handoff') .action(withErrorHandler(async (actionKind: string, targetId: string, opts: ActOptions) => { const service = new AgentActionService( ctx.graphPort, @@ -523,4 +548,57 @@ export function registerAgentCommands(program: Command, ctx: CliContext): void { renderHumanOutcome(ctx, outcome); })); + + program + .command('handoff ') + .description('Record a durable graph-native session handoff note') + .requiredOption('--message ', 'Handoff summary body') + .option('--title ', 'Optional handoff title') + .option('--related ', 'Additional related IDs to document with the handoff') + .action(withErrorHandler(async (targetId: string, opts: Pick) => { + const service = new AgentActionService( + ctx.graphPort, + new WarpRoadmapAdapter(ctx.graphPort), + ctx.agentId, + ); + + const outcome = await service.execute({ + kind: 'handoff', + targetId, + dryRun: false, + args: buildActionArgs(opts), + }); + + if (outcome.result === 'rejected') { + const reason = outcome.validation.reasons[0] ?? `Action 'handoff' was rejected`; + if (ctx.json) { + return ctx.failWithData(reason, { ...outcome }); + } + return ctx.fail(`[REJECTED] ${reason}`); + } + + const details = outcome.details ?? {}; + if (ctx.json) { + ctx.jsonOut({ + success: true, + command: 'handoff', + data: { + noteId: details['noteId'] ?? null, + authoredBy: details['authoredBy'] ?? null, + authoredAt: details['authoredAt'] ?? null, + relatedIds: details['relatedIds'] ?? [targetId], + patch: outcome.patch, + title: details['title'] ?? null, + contentOid: details['contentOid'] ?? null, + }, + }); + return; + } + + ctx.ok(`[OK] handoff ${targetId}`); + ctx.muted(` Note: ${String(details['noteId'] ?? 'unknown')}`); + ctx.muted(` Patch: ${String(outcome.patch ?? 'none')}`); + const relatedIds = Array.isArray(details['relatedIds']) ? details['relatedIds'] : [targetId]; + ctx.muted(` Related: ${relatedIds.join(', ')}`); + })); } diff --git a/src/cli/commands/show.ts b/src/cli/commands/show.ts index 4b38405..e4dbb98 100644 --- a/src/cli/commands/show.ts +++ b/src/cli/commands/show.ts @@ -71,7 +71,10 @@ function renderNarrativeLines(label: string, entries: NarrativeNode[] | CommentN for (const entry of entries) { if ('title' in entry) { const state = entry.current ? 'current' : 'history'; - lines.push(` - ${entry.id} [${entry.type}] ${entry.title} (${state})`); + const typeLabel = entry.type === 'note' && entry.noteKind + ? `${entry.type}:${entry.noteKind}` + : entry.type; + lines.push(` - ${entry.id} [${typeLabel}] ${entry.title} (${state})`); if (entry.targetIds.length > 0) { lines.push(` targets: ${entry.targetIds.join(', ')}`); } diff --git a/src/domain/models/dashboard.ts b/src/domain/models/dashboard.ts index 785b8b0..26949a8 100644 --- a/src/domain/models/dashboard.ts +++ b/src/domain/models/dashboard.ts @@ -209,6 +209,7 @@ export interface NarrativeNode { title: string; authoredBy: string; authoredAt: number; + noteKind?: string; body?: string; contentOid?: string; targetIds: string[]; diff --git a/src/domain/services/AgentActionService.ts b/src/domain/services/AgentActionService.ts index 9a2cbc4..b411cd2 100644 --- a/src/domain/services/AgentActionService.ts +++ b/src/domain/services/AgentActionService.ts @@ -18,7 +18,7 @@ import { GitWorkspaceAdapter } from '../../infrastructure/adapters/GitWorkspaceA import type { ReviewVerdict } from '../entities/Submission.js'; export const ROUTINE_AGENT_ACTION_KINDS = [ - 'claim', 'shape', 'packet', 'ready', 'comment', 'submit', 'review', + 'claim', 'shape', 'packet', 'ready', 'comment', 'submit', 'review', 'handoff', ] as const; export const HUMAN_ONLY_AGENT_ACTION_KINDS = [ @@ -128,6 +128,15 @@ interface ReviewAction { submissionId: string; } +interface HandoffAction { + kind: 'handoff'; + targetId: string; + noteId: string; + title: string; + message: string; + relatedIds: string[]; +} + type SupportedNormalizedAction = | ClaimAction | ShapeAction @@ -135,7 +144,8 @@ type SupportedNormalizedAction = | ReadyAction | CommentAction | SubmitAction - | ReviewAction; + | ReviewAction + | HandoffAction; function autoId(prefix: string): string { const ts = Date.now().toString(36).padStart(9, '0'); @@ -208,6 +218,19 @@ function derivePacketId(prefix: 'story:' | 'req:' | 'criterion:', questId: strin return `${prefix}${questId.slice('task:'.length)}`; } +function normalizeStringArray(value: unknown): string[] { + if (Array.isArray(value)) { + return value + .flatMap((entry) => typeof entry === 'string' ? [entry.trim()] : []) + .filter((entry) => entry.length > 0); + } + if (typeof value === 'string') { + const trimmed = value.trim(); + return trimmed.length > 0 ? [trimmed] : []; + } + return []; +} + export class AgentActionValidator { private readonly intake: IntakeService; private readonly readiness: ReadinessService; @@ -258,6 +281,8 @@ export class AgentActionValidator { return this.validateSubmit(request); case 'review': return this.validateReview(request); + case 'handoff': + return this.validateHandoff(request); } } @@ -809,6 +834,77 @@ export class AgentActionValidator { ], ); } + + private async validateHandoff(request: AgentActionRequest): Promise { + const message = typeof request.args['message'] === 'string' + ? request.args['message'].trim() + : ''; + if (message.length < 5) { + return failAssessment(request, 'invalid-args', [ + 'handoff requires a message of at least 5 characters', + ]); + } + + const title = typeof request.args['title'] === 'string' && request.args['title'].trim().length > 0 + ? request.args['title'].trim() + : `Handoff for ${request.targetId}`; + + const noteId = autoId('note:'); + const rawRelatedIds = normalizeStringArray(request.args['relatedIds']); + const relatedIds = [...new Set([request.targetId, ...rawRelatedIds])]; + + const graph = await this.graphPort.getGraph(); + if (!await graph.hasNode(request.targetId)) { + return failAssessment(request, 'not-found', [ + `Target ${request.targetId} not found in the graph`, + ]); + } + + for (const relatedId of rawRelatedIds) { + if (!await graph.hasNode(relatedId)) { + return failAssessment(request, 'not-found', [ + `Related target ${relatedId} not found in the graph`, + ], { + normalizedArgs: { + noteId, + title, + message, + relatedIds, + }, + underlyingCommand: `xyph handoff ${request.targetId}`, + sideEffects: [ + `create ${noteId}`, + ...relatedIds.map((id) => `documents -> ${id}`), + 'attach content blob', + ], + }); + } + } + + return successAssessment( + request, + { + kind: 'handoff', + targetId: request.targetId, + noteId, + title, + message, + relatedIds, + }, + { + noteId, + title, + message, + relatedIds, + }, + `xyph handoff ${request.targetId}`, + [ + `create ${noteId}`, + ...relatedIds.map((id) => `documents -> ${id}`), + 'attach content blob', + ], + ); + } } export class AgentActionService { @@ -874,6 +970,8 @@ export class AgentActionService { return await this.executeSubmit(assessment, normalized); case 'review': return await this.executeReview(assessment, normalized); + case 'handoff': + return await this.executeHandoff(assessment, normalized); } } catch (err) { const msg = err instanceof Error ? err.message : String(err); @@ -1158,4 +1256,41 @@ export class AgentActionService { }, }; } + + private async executeHandoff( + assessment: ValidatedAssessment, + action: HandoffAction, + ): Promise { + const graph = await this.graphPort.getGraph(); + const patch = await createPatchSession(graph); + const now = Date.now(); + patch + .addNode(action.noteId) + .setProperty(action.noteId, 'type', 'note') + .setProperty(action.noteId, 'note_kind', 'handoff') + .setProperty(action.noteId, 'title', action.title) + .setProperty(action.noteId, 'authored_by', this.agentId) + .setProperty(action.noteId, 'authored_at', now) + .setProperty(action.noteId, 'session_ended_at', now); + for (const relatedId of action.relatedIds) { + patch.addEdge(action.noteId, relatedId, 'documents'); + } + await patch.attachContent(action.noteId, action.message); + const sha = await patch.commit(); + const contentOid = await graph.getContentOid(action.noteId) ?? undefined; + + return { + ...assessment, + result: 'success', + patch: sha, + details: { + noteId: action.noteId, + title: action.title, + authoredBy: this.agentId, + authoredAt: now, + relatedIds: action.relatedIds, + contentOid: contentOid ?? null, + }, + }; + } } diff --git a/src/domain/services/AgentBriefingService.ts b/src/domain/services/AgentBriefingService.ts index df097fd..0686744 100644 --- a/src/domain/services/AgentBriefingService.ts +++ b/src/domain/services/AgentBriefingService.ts @@ -1,7 +1,9 @@ +import type { QueryResultV1, AggregateResult } from '@git-stunts/git-warp'; import type { GraphMeta, GraphSnapshot, QuestNode } from '../models/dashboard.js'; import type { GraphPort } from '../../ports/GraphPort.js'; import type { RoadmapQueryPort } from '../../ports/RoadmapPort.js'; import { createGraphContext } from '../../infrastructure/GraphContext.js'; +import { toNeighborEntries } from '../../infrastructure/helpers/isNeighborEntry.js'; import { ReadinessService } from './ReadinessService.js'; import { AgentActionValidator } from './AgentActionService.js'; import { @@ -15,6 +17,18 @@ import { toAgentQuestRef, } from './AgentContextService.js'; +interface QNode { + id: string; + props: Record; +} + +function extractNodes(result: QueryResultV1 | AggregateResult): QNode[] { + if (!('nodes' in result)) return []; + return result.nodes.filter( + (node): node is QNode => typeof node.id === 'string' && node.props !== undefined, + ); +} + export interface AgentBriefingIdentity { agentId: string; principalType: 'human' | 'agent'; @@ -43,11 +57,19 @@ export interface AgentReviewQueueEntry { reason: string; } +export interface AgentHandoffSummary { + noteId: string; + title: string; + authoredAt: number; + relatedIds: string[]; +} + export interface AgentBriefing { identity: AgentBriefingIdentity; assignments: AgentWorkSummary[]; reviewQueue: AgentReviewQueueEntry[]; frontier: AgentWorkSummary[]; + recentHandoffs: AgentHandoffSummary[]; alerts: AgentBriefingAlert[]; graphMeta: GraphMeta | null; } @@ -116,6 +138,7 @@ export class AgentBriefingService { ); const reviewQueue = this.buildReviewQueue(snapshot); + const recentHandoffs = await this.buildRecentHandoffs(); const alerts = this.buildAlerts(assignments, frontier, reviewQueue); return { @@ -126,6 +149,7 @@ export class AgentBriefingService { assignments, reviewQueue, frontier, + recentHandoffs, alerts, graphMeta: snapshot.graphMeta ?? null, }; @@ -251,4 +275,45 @@ export class AgentBriefingService { return alerts; } + + private async buildRecentHandoffs(limit = 5): Promise { + const graph = await this.graphPort.getGraph(); + const noteNodes = await graph.query() + .match('note:*') + .select(['id', 'props']) + .run() + .then(extractNodes); + + const summaries = await Promise.all(noteNodes.map(async (node) => { + const title = node.props['title']; + const authoredBy = node.props['authored_by']; + const authoredAt = node.props['authored_at']; + if ( + node.props['type'] !== 'note' || + node.props['note_kind'] !== 'handoff' || + authoredBy !== this.agentId || + typeof title !== 'string' || + typeof authoredAt !== 'number' + ) { + return null; + } + + const relatedIds = toNeighborEntries(await graph.neighbors(node.id, 'outgoing')) + .filter((edge) => edge.label === 'documents') + .map((edge) => edge.nodeId) + .sort((a, b) => a.localeCompare(b)); + + return { + noteId: node.id, + title, + authoredAt, + relatedIds, + } satisfies AgentHandoffSummary; + })); + + return summaries + .filter((entry): entry is AgentHandoffSummary => entry !== null) + .sort((a, b) => b.authoredAt - a.authoredAt || a.noteId.localeCompare(b.noteId)) + .slice(0, limit); + } } diff --git a/src/infrastructure/GraphContext.ts b/src/infrastructure/GraphContext.ts index 41903f2..42c6c2d 100644 --- a/src/infrastructure/GraphContext.ts +++ b/src/infrastructure/GraphContext.ts @@ -1087,6 +1087,7 @@ class GraphContextImpl implements GraphContext { title: string; authoredBy: string; authoredAt: number; + noteKind?: string; targetIds: string[]; supersedesId?: string; }>(); @@ -1127,6 +1128,9 @@ class GraphContextImpl implements GraphContext { title, authoredBy, authoredAt, + noteKind: rawType === 'note' && typeof node.props['note_kind'] === 'string' + ? node.props['note_kind'] + : undefined, targetIds: targetRefs, supersedesId, }); @@ -1188,6 +1192,7 @@ class GraphContextImpl implements GraphContext { title: doc.title, authoredBy: doc.authoredBy, authoredAt: doc.authoredAt, + noteKind: doc.noteKind, body: content?.body, contentOid: content?.contentOid, targetIds: doc.targetIds.filter((targetId) => targetIds.has(targetId)), @@ -1445,11 +1450,14 @@ class GraphContextImpl implements GraphContext { }); } for (const document of documents) { + const title = document.type === 'note' && document.noteKind === 'handoff' + ? `Handoff: ${document.title}` + : document.title; entries.push({ id: document.id, at: document.authoredAt, kind: document.type, - title: document.title, + title, actor: document.authoredBy, relatedId: document.targetIds[0], }); diff --git a/test/unit/AgentActionService.test.ts b/test/unit/AgentActionService.test.ts index 856a5c0..6639bb5 100644 --- a/test/unit/AgentActionService.test.ts +++ b/test/unit/AgentActionService.test.ts @@ -269,6 +269,91 @@ describe('AgentActionService', () => { }); }); + it('normalizes handoff during dry-run with target and related document links', async () => { + const graph = { + hasNode: vi.fn(async (id: string) => ['task:AGT-001', 'submission:AGT-001'].includes(id)), + }; + const service = new AgentActionService( + makeGraphPort(graph), + makeRoadmap(makeQuest()), + 'agent.hal', + ); + + const outcome = await service.execute({ + kind: 'handoff', + targetId: 'task:AGT-001', + dryRun: true, + args: { + title: 'Session closeout', + message: 'Wrapped the review loop slice and leaving next-step notes.', + relatedIds: ['submission:AGT-001'], + }, + }); + + expect(outcome).toMatchObject({ + kind: 'handoff', + targetId: 'task:AGT-001', + allowed: true, + result: 'dry-run', + underlyingCommand: 'xyph handoff task:AGT-001', + normalizedArgs: { + title: 'Session closeout', + message: 'Wrapped the review loop slice and leaving next-step notes.', + relatedIds: ['task:AGT-001', 'submission:AGT-001'], + }, + }); + expect(typeof outcome.normalizedArgs['noteId']).toBe('string'); + }); + + it('writes graph-native handoff notes with attached content and document links', async () => { + const graph = { + hasNode: vi.fn(async (id: string) => ['task:AGT-001', 'submission:AGT-001'].includes(id)), + getContentOid: vi.fn(async () => 'oid:handoff'), + }; + const patch = makePatchSession(); + patch.commit = vi.fn(async () => 'patch:handoff'); + mocks.createPatchSession.mockResolvedValue(patch); + + const service = new AgentActionService( + makeGraphPort(graph), + makeRoadmap(makeQuest()), + 'agent.hal', + ); + + const outcome = await service.execute({ + kind: 'handoff', + targetId: 'task:AGT-001', + args: { + title: 'Session closeout', + message: 'Wrapped the review loop slice and leaving next-step notes.', + relatedIds: ['submission:AGT-001'], + }, + }); + + expect(patch.setProperty).toHaveBeenCalledWith(expect.any(String), 'note_kind', 'handoff'); + expect(patch.addEdge).toHaveBeenCalledWith(expect.any(String), 'task:AGT-001', 'documents'); + expect(patch.addEdge).toHaveBeenCalledWith(expect.any(String), 'submission:AGT-001', 'documents'); + expect(patch.attachContent).toHaveBeenCalledWith( + expect.any(String), + 'Wrapped the review loop slice and leaving next-step notes.', + ); + expect(outcome).toMatchObject({ + kind: 'handoff', + targetId: 'task:AGT-001', + allowed: true, + result: 'success', + patch: 'patch:handoff', + details: { + title: 'Session closeout', + authoredBy: 'agent.hal', + relatedIds: ['task:AGT-001', 'submission:AGT-001'], + contentOid: 'oid:handoff', + }, + }); + expect(typeof outcome.details?.['noteId']).toBe('string'); + expect(typeof outcome.details?.['authoredAt']).toBe('number'); + }); + it('normalizes submit during dry-run with workspace metadata and generated ids', async () => { const service = new AgentActionService( makeGraphPort({}), diff --git a/test/unit/AgentBriefingService.test.ts b/test/unit/AgentBriefingService.test.ts index 89adac2..98f0a56 100644 --- a/test/unit/AgentBriefingService.test.ts +++ b/test/unit/AgentBriefingService.test.ts @@ -13,9 +13,19 @@ vi.mock('../../src/infrastructure/GraphContext.js', () => ({ createGraphContext: (graphPort: unknown) => mocks.createGraphContext(graphPort), })); -function makeGraphPort(): GraphPort { +function makeGraphWithHandoffs(noteNodes: { id: string; props: Record }[], outgoing: Record = {}): GraphPort { + const graph = { + query: vi.fn(() => ({ + match: vi.fn(() => ({ + select: vi.fn(() => ({ + run: vi.fn(async () => ({ nodes: noteNodes })), + })), + })), + })), + neighbors: vi.fn(async (id: string) => outgoing[id] ?? []), + }; return { - getGraph: vi.fn(), + getGraph: vi.fn(async () => graph), reset: vi.fn(), }; } @@ -121,7 +131,23 @@ describe('AgentBriefingService', () => { ]; const service = new AgentBriefingService( - makeGraphPort(), + makeGraphWithHandoffs([ + { + id: 'note:handoff-1', + props: { + type: 'note', + note_kind: 'handoff', + title: 'Wrapped READY gating', + authored_by: 'agent.hal', + authored_at: 150, + }, + }, + ], { + 'note:handoff-1': [ + { nodeId: 'task:AGT-001', label: 'documents' }, + { nodeId: 'submission:AGT-001', label: 'documents' }, + ], + }), makeRoadmap( questEntities, { @@ -172,6 +198,14 @@ describe('AgentBriefingService', () => { status: 'OPEN', }, ]); + expect(briefing.recentHandoffs).toEqual([ + { + noteId: 'note:handoff-1', + title: 'Wrapped READY gating', + authoredAt: 150, + relatedIds: ['submission:AGT-001', 'task:AGT-001'], + }, + ]); expect(briefing.graphMeta?.tipSha).toBe('abc1234'); expect(briefing.alerts.map((alert) => alert.code)).toContain('review-queue'); }); @@ -217,7 +251,7 @@ describe('AgentBriefingService', () => { }); const service = new AgentBriefingService( - makeGraphPort(), + makeGraphWithHandoffs([]), makeRoadmap( [ makeQuestEntity({ diff --git a/test/unit/AgentCommands.test.ts b/test/unit/AgentCommands.test.ts index a84c753..3207391 100644 --- a/test/unit/AgentCommands.test.ts +++ b/test/unit/AgentCommands.test.ts @@ -87,6 +87,7 @@ describe('agent act command', () => { assignments: [], reviewQueue: [], frontier: [], + recentHandoffs: [], alerts: [], graphMeta: { maxTick: 42, @@ -114,6 +115,7 @@ describe('agent act command', () => { assignments: [], reviewQueue: [], frontier: [], + recentHandoffs: [], alerts: [], graphMeta: { maxTick: 42, @@ -739,6 +741,123 @@ describe('agent act command', () => { }); }); + it('maps handoff options into normalized action args', async () => { + mocks.execute.mockResolvedValue({ + kind: 'handoff', + targetId: 'task:AGT-001', + allowed: true, + dryRun: true, + requiresHumanApproval: false, + validation: { + valid: true, + code: null, + reasons: [], + }, + normalizedArgs: {}, + underlyingCommand: 'xyph handoff task:AGT-001', + sideEffects: [], + result: 'dry-run', + patch: null, + details: null, + }); + + const ctx = makeCtx(); + const program = new Command(); + registerAgentCommands(program, ctx); + + await program.parseAsync([ + 'act', + 'handoff', + 'task:AGT-001', + '--title', + 'Session closeout', + '--message', + 'Wrapped the slice and leaving a durable handoff.', + '--related', + 'submission:AGT-001', + 'campaign:AGT', + '--dry-run', + ], { from: 'user' }); + + expect(mocks.execute).toHaveBeenCalledWith({ + kind: 'handoff', + targetId: 'task:AGT-001', + dryRun: true, + args: { + title: 'Session closeout', + message: 'Wrapped the slice and leaving a durable handoff.', + relatedIds: ['submission:AGT-001', 'campaign:AGT'], + }, + }); + }); + + it('emits the specialized handoff JSON envelope', async () => { + mocks.execute.mockResolvedValue({ + kind: 'handoff', + targetId: 'task:AGT-001', + allowed: true, + dryRun: false, + requiresHumanApproval: false, + validation: { + valid: true, + code: null, + reasons: [], + }, + normalizedArgs: {}, + underlyingCommand: 'xyph handoff task:AGT-001', + sideEffects: ['create note:handoff-1'], + result: 'success', + patch: 'patch:handoff', + details: { + noteId: 'note:handoff-1', + authoredBy: 'agent.hal', + authoredAt: 1_700_000_000_000, + relatedIds: ['task:AGT-001', 'submission:AGT-001'], + title: 'Session closeout', + contentOid: 'oid:handoff', + }, + }); + + const ctx = makeCtx(); + const program = new Command(); + registerAgentCommands(program, ctx); + + await program.parseAsync([ + 'handoff', + 'task:AGT-001', + '--title', + 'Session closeout', + '--message', + 'Wrapped the slice and leaving a durable handoff.', + '--related', + 'submission:AGT-001', + ], { from: 'user' }); + + expect(mocks.execute).toHaveBeenCalledWith({ + kind: 'handoff', + targetId: 'task:AGT-001', + dryRun: false, + args: { + title: 'Session closeout', + message: 'Wrapped the slice and leaving a durable handoff.', + relatedIds: ['submission:AGT-001'], + }, + }); + expect(ctx.jsonOut).toHaveBeenCalledWith({ + success: true, + command: 'handoff', + data: { + noteId: 'note:handoff-1', + authoredBy: 'agent.hal', + authoredAt: 1_700_000_000_000, + relatedIds: ['task:AGT-001', 'submission:AGT-001'], + patch: 'patch:handoff', + title: 'Session closeout', + contentOid: 'oid:handoff', + }, + }); + }); + it('routes rejected actions through the JSON error envelope', async () => { const rejected = { kind: 'promote', From 14a29fdf26b3a2115fd341e3d77be42cfd03fa36 Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 12 Mar 2026 19:32:50 -0700 Subject: [PATCH 12/22] Add seal and merge to agent action kernel --- docs/canonical/AGENT_PROTOCOL.md | 11 +- src/cli/commands/agent.ts | 12 + src/cli/commands/artifact.ts | 50 +- src/cli/commands/submission.ts | 2 +- src/domain/services/AgentActionService.ts | 459 +++++++++++++++++- src/domain/services/AgentSubmissionService.ts | 2 +- src/domain/services/SettlementKeyPolicy.ts | 35 ++ src/domain/services/SubmissionService.ts | 7 +- test/unit/AgentActionService.test.ts | 304 ++++++++++++ test/unit/AgentCommands.test.ts | 103 +++- test/unit/AgentSubmissionService.test.ts | 1 + test/unit/SubmissionService.test.ts | 21 +- 12 files changed, 950 insertions(+), 57 deletions(-) create mode 100644 src/domain/services/SettlementKeyPolicy.ts diff --git a/docs/canonical/AGENT_PROTOCOL.md b/docs/canonical/AGENT_PROTOCOL.md index dcfe653..c286418 100644 --- a/docs/canonical/AGENT_PROTOCOL.md +++ b/docs/canonical/AGENT_PROTOCOL.md @@ -70,9 +70,8 @@ Current runtime tranche: - shipped now: `context ` - shipped now: `submissions` - shipped now: `handoff` -- shipped now: `claim`, `shape`, `packet`, `ready`, `comment`, `submit`, `review`, `handoff` +- shipped now: `claim`, `shape`, `packet`, `ready`, `comment`, `submit`, `review`, `handoff`, `seal`, `merge` - shipped now: `act ` for that subset -- planned later in checkpoint 2: `seal`, `merge` ### 3.1 `show` vs `context` @@ -192,10 +191,10 @@ Checkpoint-2 action kinds are: - `seal` - `merge` -These are the only routine agent actions that should be executable through -`act` in the checkpoint-2 kernel. +These are the routine agent actions that should be executable through `act` in +the checkpoint-2 kernel. -The current runtime implementation ships the first tranche only: +The current runtime now ships that routine action set: - `claim` - `shape` @@ -205,6 +204,8 @@ The current runtime implementation ships the first tranche only: - `submit` - `review` - `handoff` +- `seal` +- `merge` ### 5.1 Human-only actions diff --git a/src/cli/commands/agent.ts b/src/cli/commands/agent.ts index f7170df..f85cf47 100644 --- a/src/cli/commands/agent.ts +++ b/src/cli/commands/agent.ts @@ -25,8 +25,12 @@ interface ActOptions { dryRun?: boolean; description?: string; title?: string; + rationale?: string; + artifact?: string; base?: string; workspace?: string; + into?: string; + patchset?: string; kind?: string; story?: string; storyTitle?: string; @@ -51,8 +55,12 @@ function buildActionArgs(opts: ActOptions): Record { const args: Record = {}; if (opts.description !== undefined) args['description'] = opts.description.trim(); if (opts.title !== undefined) args['title'] = opts.title.trim(); + if (opts.rationale !== undefined) args['rationale'] = opts.rationale.trim(); + if (opts.artifact !== undefined) args['artifactHash'] = opts.artifact.trim(); if (opts.base !== undefined) args['baseRef'] = opts.base.trim(); if (opts.workspace !== undefined) args['workspaceRef'] = opts.workspace.trim(); + if (opts.into !== undefined) args['intoRef'] = opts.into.trim(); + if (opts.patchset !== undefined) args['patchsetId'] = opts.patchset.trim(); if (opts.kind !== undefined) args['taskKind'] = opts.kind; if (opts.story !== undefined) args['storyId'] = opts.story; if (opts.storyTitle !== undefined) args['storyTitle'] = opts.storyTitle.trim(); @@ -495,8 +503,12 @@ export function registerAgentCommands(program: Command, ctx: CliContext): void { .option('--dry-run', 'Validate and normalize without mutating graph or workspace') .option('--description ', 'Description for shape or submit') .option('--title ', 'Title for handoff') + .option('--rationale ', 'Rationale for seal or merge') + .option('--artifact ', 'Artifact hash for seal') .option('--base ', 'Base branch for submit (default: main)') .option('--workspace ', 'Workspace ref for submit (default: current git branch)') + .option('--into ', 'Target branch for merge (default: main)') + .option('--patchset ', 'Explicit patchset ID for merge') .option('--kind ', `Quest kind for shape (${[...VALID_TASK_KINDS].join(' | ')})`) .option('--story ', 'Story node ID for packet') .option('--story-title ', 'Story title for packet') diff --git a/src/cli/commands/artifact.ts b/src/cli/commands/artifact.ts index 67064cd..3839e73 100644 --- a/src/cli/commands/artifact.ts +++ b/src/cli/commands/artifact.ts @@ -1,47 +1,25 @@ import type { Command } from 'commander'; import type { CliContext } from '../context.js'; import { createErrorHandler } from '../errorHandler.js'; +import { + allowUnsignedScrollsForSettlement, + formatMissingSettlementKeyMessage, + formatUnsignedScrollOverrideWarning, + missingSettlementKeyData, + UNSIGNED_SCROLLS_OVERRIDE_ENV, +} from '../../domain/services/SettlementKeyPolicy.js'; import { assessSettlementGate, formatSettlementGateFailure, settlementGateFailureData, } from '../../domain/services/SettlementGateService.js'; - -export const UNSIGNED_SCROLLS_OVERRIDE_ENV = 'XYPH_ALLOW_UNSIGNED_SCROLLS'; - -export function allowUnsignedScrollsForSettlement( - env: NodeJS.ProcessEnv = process.env, -): boolean { - const override = env[UNSIGNED_SCROLLS_OVERRIDE_ENV]?.trim().toLowerCase(); - if (override === '1' || override === 'true') return true; - const vitest = env['VITEST']?.trim().toLowerCase(); - if (vitest && vitest !== '0' && vitest !== 'false') return true; - return env['NODE_ENV'] === 'test'; -} - -export function formatUnsignedScrollOverrideWarning(agentId: string): string { - return `No private key found for ${agentId} — unsigned scroll allowed because ${UNSIGNED_SCROLLS_OVERRIDE_ENV}=1 or test mode is enabled`; -} - -export function formatMissingSettlementKeyMessage( - agentId: string, - action: 'seal' | 'merge', -): string { - return `Missing private key for ${agentId}. Generate a Guild Seal key before '${action}' or set ${UNSIGNED_SCROLLS_OVERRIDE_ENV}=1 for dev/test only.`; -} - -export function missingSettlementKeyData( - agentId: string, - action: 'seal' | 'merge', -): Record { - return { - agentId, - action, - missing: 'guild-seal-private-key', - overrideEnvVar: UNSIGNED_SCROLLS_OVERRIDE_ENV, - hint: `Run 'xyph-actuator generate-key' before '${action}', or set ${UNSIGNED_SCROLLS_OVERRIDE_ENV}=1 for dev/test only.`, - }; -} +export { + allowUnsignedScrollsForSettlement, + formatMissingSettlementKeyMessage, + formatUnsignedScrollOverrideWarning, + missingSettlementKeyData, + UNSIGNED_SCROLLS_OVERRIDE_ENV, +}; export function registerArtifactCommands(program: Command, ctx: CliContext): void { const withErrorHandler = createErrorHandler(ctx); diff --git a/src/cli/commands/submission.ts b/src/cli/commands/submission.ts index 55615bc..cd33a81 100644 --- a/src/cli/commands/submission.ts +++ b/src/cli/commands/submission.ts @@ -8,7 +8,7 @@ import { formatMissingSettlementKeyMessage, formatUnsignedScrollOverrideWarning, missingSettlementKeyData, -} from './artifact.js'; +} from '../../domain/services/SettlementKeyPolicy.js'; import { assessSettlementGate, formatSettlementGateFailure, diff --git a/src/domain/services/AgentActionService.ts b/src/domain/services/AgentActionService.ts index b411cd2..c51112e 100644 --- a/src/domain/services/AgentActionService.ts +++ b/src/domain/services/AgentActionService.ts @@ -11,14 +11,26 @@ import { import { IntakeService } from './IntakeService.js'; import { ReadinessService } from './ReadinessService.js'; import { SubmissionService } from './SubmissionService.js'; +import { GuildSealService } from './GuildSealService.js'; +import { + assessSettlementGate, + formatSettlementGateFailure, +} from './SettlementGateService.js'; +import { + allowUnsignedScrollsForSettlement, + formatMissingSettlementKeyMessage, + formatUnsignedScrollOverrideWarning, +} from './SettlementKeyPolicy.js'; import { createPatchSession } from '../../infrastructure/helpers/createPatchSession.js'; +import { createGraphContext } from '../../infrastructure/GraphContext.js'; +import { FsKeyringAdapter } from '../../infrastructure/adapters/FsKeyringAdapter.js'; import { WarpIntakeAdapter } from '../../infrastructure/adapters/WarpIntakeAdapter.js'; import { WarpSubmissionAdapter } from '../../infrastructure/adapters/WarpSubmissionAdapter.js'; import { GitWorkspaceAdapter } from '../../infrastructure/adapters/GitWorkspaceAdapter.js'; import type { ReviewVerdict } from '../entities/Submission.js'; export const ROUTINE_AGENT_ACTION_KINDS = [ - 'claim', 'shape', 'packet', 'ready', 'comment', 'submit', 'review', 'handoff', + 'claim', 'shape', 'packet', 'ready', 'comment', 'submit', 'review', 'handoff', 'seal', 'merge', ] as const; export const HUMAN_ONLY_AGENT_ACTION_KINDS = [ @@ -137,6 +149,24 @@ interface HandoffAction { relatedIds: string[]; } +interface SealAction { + kind: 'seal'; + targetId: string; + artifactHash: string; + rationale: string; +} + +interface MergeAction { + kind: 'merge'; + targetId: string; + rationale: string; + intoRef: string; + tipPatchsetId: string; + explicitPatchsetId?: string; + questId?: string; + shouldAutoSeal: boolean; +} + type SupportedNormalizedAction = | ClaimAction | ShapeAction @@ -145,7 +175,9 @@ type SupportedNormalizedAction = | CommentAction | SubmitAction | ReviewAction - | HandoffAction; + | HandoffAction + | SealAction + | MergeAction; function autoId(prefix: string): string { const ts = Date.now().toString(36).padStart(9, '0'); @@ -283,6 +315,10 @@ export class AgentActionValidator { return this.validateReview(request); case 'handoff': return this.validateHandoff(request); + case 'seal': + return this.validateSeal(request); + case 'merge': + return this.validateMerge(request); } } @@ -905,6 +941,238 @@ export class AgentActionValidator { ], ); } + + private async validateSeal(request: AgentActionRequest): Promise { + if (!request.targetId.startsWith('task:')) { + return failAssessment(request, 'invalid-target', [ + `seal requires a task:* target, got '${request.targetId}'`, + ]); + } + + const artifactHash = typeof request.args['artifactHash'] === 'string' + ? request.args['artifactHash'].trim() + : ''; + if (artifactHash.length < 3) { + return failAssessment(request, 'invalid-args', [ + 'seal requires an artifactHash of at least 3 characters', + ]); + } + + const rationale = typeof request.args['rationale'] === 'string' + ? request.args['rationale'].trim() + : ''; + if (rationale.length < 3) { + return failAssessment(request, 'invalid-args', [ + 'seal requires a rationale of at least 3 characters', + ]); + } + + const graphCtx = createGraphContext(this.graphPort); + const detail = await graphCtx.fetchEntityDetail(request.targetId); + const gate = assessSettlementGate(detail?.questDetail, 'seal'); + if (!gate.allowed) { + return failAssessment(request, gate.code ?? 'precondition-failed', [ + formatSettlementGateFailure(gate), + ], { + normalizedArgs: { + artifactHash, + rationale, + }, + underlyingCommand: `xyph seal ${request.targetId}`, + sideEffects: [ + `create artifact:${request.targetId}`, + 'status -> DONE', + 'completed_at -> now', + ], + }); + } + + const keyring = new FsKeyringAdapter(); + const sealService = new GuildSealService(keyring); + if (!sealService.hasPrivateKey(this.agentId) && !allowUnsignedScrollsForSettlement()) { + return failAssessment(request, 'missing-private-key', [ + formatMissingSettlementKeyMessage(this.agentId, 'seal'), + ], { + normalizedArgs: { + artifactHash, + rationale, + }, + underlyingCommand: `xyph seal ${request.targetId}`, + sideEffects: [ + `create artifact:${request.targetId}`, + 'status -> DONE', + 'completed_at -> now', + ], + }); + } + + return successAssessment( + request, + { + kind: 'seal', + targetId: request.targetId, + artifactHash, + rationale, + }, + { + artifactHash, + rationale, + }, + `xyph seal ${request.targetId}`, + [ + `create artifact:${request.targetId}`, + 'status -> DONE', + 'completed_at -> now', + ], + ); + } + + private async validateMerge(request: AgentActionRequest): Promise { + if (!request.targetId.startsWith('submission:')) { + return failAssessment(request, 'invalid-target', [ + `merge requires a submission:* target, got '${request.targetId}'`, + ]); + } + + const rationale = typeof request.args['rationale'] === 'string' + ? request.args['rationale'].trim() + : ''; + if (rationale.length < 3) { + return failAssessment(request, 'invalid-args', [ + 'merge requires a rationale of at least 3 characters', + ]); + } + + const intoRef = typeof request.args['intoRef'] === 'string' && request.args['intoRef'].trim().length > 0 + ? request.args['intoRef'].trim() + : 'main'; + const explicitPatchsetId = typeof request.args['patchsetId'] === 'string' && request.args['patchsetId'].trim().length > 0 + ? request.args['patchsetId'].trim() + : undefined; + + const adapter = new WarpSubmissionAdapter(this.graphPort, this.agentId); + let tipPatchsetId: string; + try { + const result = await this.submissions.validateMerge(request.targetId, this.agentId, explicitPatchsetId); + tipPatchsetId = result.tipPatchsetId; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return failAssessment(request, 'precondition-failed', [msg], { + normalizedArgs: { + rationale, + intoRef, + patchsetId: explicitPatchsetId ?? null, + }, + underlyingCommand: `xyph merge ${request.targetId}`, + sideEffects: [ + `merge submission into ${intoRef}`, + 'create merge decision', + 'auto-seal quest when needed', + ], + }); + } + + const questId = await adapter.getSubmissionQuestId(request.targetId) ?? undefined; + const questStatus = questId ? await adapter.getQuestStatus(questId) : null; + const shouldAutoSeal = typeof questId === 'string' && questStatus !== 'DONE'; + + if (shouldAutoSeal && questId) { + const graphCtx = createGraphContext(this.graphPort); + const detail = await graphCtx.fetchEntityDetail(questId); + const gate = assessSettlementGate(detail?.questDetail, 'merge'); + if (!gate.allowed) { + return failAssessment(request, gate.code ?? 'precondition-failed', [ + formatSettlementGateFailure(gate), + ], { + normalizedArgs: { + rationale, + intoRef, + patchsetId: explicitPatchsetId ?? null, + tipPatchsetId, + questId, + }, + underlyingCommand: `xyph merge ${request.targetId}`, + sideEffects: [ + `merge submission into ${intoRef}`, + 'create merge decision', + 'auto-seal quest when needed', + ], + }); + } + + const keyring = new FsKeyringAdapter(); + const sealService = new GuildSealService(keyring); + if (!sealService.hasPrivateKey(this.agentId) && !allowUnsignedScrollsForSettlement()) { + return failAssessment(request, 'missing-private-key', [ + formatMissingSettlementKeyMessage(this.agentId, 'merge'), + ], { + normalizedArgs: { + rationale, + intoRef, + patchsetId: explicitPatchsetId ?? null, + tipPatchsetId, + questId, + }, + underlyingCommand: `xyph merge ${request.targetId}`, + sideEffects: [ + `merge submission into ${intoRef}`, + 'create merge decision', + 'auto-seal quest when needed', + ], + }); + } + } + + const workspaceRef = await adapter.getPatchsetWorkspaceRef(tipPatchsetId); + if (typeof workspaceRef !== 'string') { + return failAssessment(request, 'workspace-resolution-failed', [ + `Could not resolve workspace ref from patchset ${tipPatchsetId}`, + ], { + normalizedArgs: { + rationale, + intoRef, + patchsetId: explicitPatchsetId ?? null, + tipPatchsetId, + questId: questId ?? null, + }, + underlyingCommand: `xyph merge ${request.targetId}`, + sideEffects: [ + `merge submission into ${intoRef}`, + 'create merge decision', + 'auto-seal quest when needed', + ], + }); + } + + return successAssessment( + request, + { + kind: 'merge', + targetId: request.targetId, + rationale, + intoRef, + tipPatchsetId, + explicitPatchsetId, + questId, + shouldAutoSeal, + }, + { + rationale, + intoRef, + patchsetId: explicitPatchsetId ?? null, + tipPatchsetId, + questId: questId ?? null, + shouldAutoSeal, + workspaceRef, + }, + `xyph merge ${request.targetId}`, + [ + `merge ${workspaceRef} into ${intoRef}`, + 'create merge decision', + ...(shouldAutoSeal ? ['auto-seal quest'] : []), + ], + ); + } } export class AgentActionService { @@ -972,6 +1240,10 @@ export class AgentActionService { return await this.executeReview(assessment, normalized); case 'handoff': return await this.executeHandoff(assessment, normalized); + case 'seal': + return await this.executeSeal(assessment, normalized); + case 'merge': + return await this.executeMerge(assessment, normalized); } } catch (err) { const msg = err instanceof Error ? err.message : String(err); @@ -1293,4 +1565,187 @@ export class AgentActionService { }, }; } + + private async executeSeal( + assessment: ValidatedAssessment, + action: SealAction, + ): Promise { + const keyring = new FsKeyringAdapter(); + const sealService = new GuildSealService(keyring); + const allowUnsignedScrolls = allowUnsignedScrollsForSettlement(); + + let openSubWarning: string | undefined; + try { + const adapter = new WarpSubmissionAdapter(this.graphPort, this.agentId); + const openSubs = await adapter.getOpenSubmissionsForQuest(action.targetId); + if (openSubs.length > 0) { + openSubWarning = `Quest ${action.targetId} has open submission ${openSubs[0]}. Consider using 'merge' instead.`; + } + } catch { + // Non-fatal: preserve raw seal behavior. + } + + if (!sealService.hasPrivateKey(this.agentId) && !allowUnsignedScrolls) { + return { + ...assessment, + allowed: false, + validation: { + valid: false, + code: 'missing-private-key', + reasons: [formatMissingSettlementKeyMessage(this.agentId, 'seal')], + }, + result: 'rejected', + patch: null, + details: null, + }; + } + + const now = Date.now(); + const scrollPayload = { + artifactHash: action.artifactHash, + questId: action.targetId, + rationale: action.rationale, + sealedBy: this.agentId, + sealedAt: now, + }; + const guildSeal = await sealService.sign(scrollPayload, this.agentId); + + const graph = await this.graphPort.getGraph(); + const scrollId = `artifact:${action.targetId}`; + const sha = await graph.patch((p) => { + p.addNode(scrollId) + .setProperty(scrollId, 'artifact_hash', action.artifactHash) + .setProperty(scrollId, 'rationale', action.rationale) + .setProperty(scrollId, 'type', 'scroll') + .setProperty(scrollId, 'sealed_by', this.agentId) + .setProperty(scrollId, 'sealed_at', now) + .setProperty(scrollId, 'payload_digest', sealService.payloadDigest(scrollPayload)) + .addEdge(scrollId, action.targetId, 'fulfills'); + + if (guildSeal) { + p.setProperty(scrollId, 'guild_seal_alg', guildSeal.alg) + .setProperty(scrollId, 'guild_seal_key_id', guildSeal.keyId) + .setProperty(scrollId, 'guild_seal_sig', guildSeal.sig); + } + + p.setProperty(action.targetId, 'status', 'DONE') + .setProperty(action.targetId, 'completed_at', now); + }); + + const warnings: string[] = []; + if (openSubWarning) warnings.push(openSubWarning); + if (!guildSeal) warnings.push(formatUnsignedScrollOverrideWarning(this.agentId)); + + return { + ...assessment, + result: 'success', + patch: sha, + details: { + id: action.targetId, + scrollId, + artifactHash: action.artifactHash, + rationale: action.rationale, + sealedBy: this.agentId, + sealedAt: now, + guildSeal: guildSeal ? { keyId: guildSeal.keyId, alg: guildSeal.alg } : null, + warnings, + }, + }; + } + + private async executeMerge( + assessment: ValidatedAssessment, + action: MergeAction, + ): Promise { + const adapter = new WarpSubmissionAdapter(this.graphPort, this.agentId); + const workspaceRef = await adapter.getPatchsetWorkspaceRef(action.tipPatchsetId); + if (typeof workspaceRef !== 'string') { + throw new Error(`Could not resolve workspace ref from patchset ${action.tipPatchsetId}`); + } + + const workspace = new GitWorkspaceAdapter(process.cwd()); + let mergeCommit: string | undefined; + const alreadyMerged = await workspace.isMerged(workspaceRef, action.intoRef); + if (alreadyMerged) { + mergeCommit = await workspace.getHeadCommit(action.intoRef); + if (!mergeCommit) { + throw new Error(`Could not resolve HEAD of ${action.intoRef}`); + } + } else { + mergeCommit = await workspace.merge(workspaceRef, action.intoRef); + } + + const decisionId = autoId('decision:'); + const { patchSha } = await adapter.decide({ + submissionId: action.targetId, + decisionId, + kind: 'merge', + rationale: action.rationale, + mergeCommit, + }); + + let autoSealed = false; + let guildSealInfo: { keyId: string; alg: string } | null = null; + let unsignedScrollWarning: string | null = null; + if (action.questId && action.shouldAutoSeal) { + const now = Date.now(); + const keyring = new FsKeyringAdapter(); + const sealService = new GuildSealService(keyring); + const scrollPayload = { + artifactHash: mergeCommit ?? 'unknown', + questId: action.questId, + rationale: action.rationale, + sealedBy: this.agentId, + sealedAt: now, + }; + const guildSeal = await sealService.sign(scrollPayload, this.agentId); + + const sealGraph = await this.graphPort.getGraph(); + const scrollId = `artifact:${action.questId}`; + await sealGraph.patch((p) => { + p.addNode(scrollId) + .setProperty(scrollId, 'artifact_hash', mergeCommit ?? 'unknown') + .setProperty(scrollId, 'rationale', action.rationale) + .setProperty(scrollId, 'type', 'scroll') + .setProperty(scrollId, 'sealed_by', this.agentId) + .setProperty(scrollId, 'sealed_at', now) + .setProperty(scrollId, 'payload_digest', sealService.payloadDigest(scrollPayload)) + .addEdge(scrollId, action.questId as string, 'fulfills'); + + if (guildSeal) { + p.setProperty(scrollId, 'guild_seal_alg', guildSeal.alg) + .setProperty(scrollId, 'guild_seal_key_id', guildSeal.keyId) + .setProperty(scrollId, 'guild_seal_sig', guildSeal.sig); + } + + p.setProperty(action.questId as string, 'status', 'DONE') + .setProperty(action.questId as string, 'completed_at', now); + }); + + autoSealed = true; + if (guildSeal) guildSealInfo = { keyId: guildSeal.keyId, alg: guildSeal.alg }; + if (!guildSeal) { + unsignedScrollWarning = formatUnsignedScrollOverrideWarning(this.agentId); + } + } + + const warnings: string[] = []; + if (unsignedScrollWarning) warnings.push(unsignedScrollWarning); + + return { + ...assessment, + result: 'success', + patch: patchSha, + details: { + submissionId: action.targetId, + decisionId, + questId: action.questId ?? null, + mergeCommit: mergeCommit ?? null, + alreadyMerged, + autoSealed, + guildSeal: guildSealInfo, + warnings, + }, + }; + } } diff --git a/src/domain/services/AgentSubmissionService.ts b/src/domain/services/AgentSubmissionService.ts index 7d294d2..856396a 100644 --- a/src/domain/services/AgentSubmissionService.ts +++ b/src/domain/services/AgentSubmissionService.ts @@ -208,7 +208,7 @@ export class AgentSubmissionService { kind: 'merge', targetId: submission.id, reason: 'Submission is approved and ready for settlement.', - supportedByActionKernel: false, + supportedByActionKernel: true, }; } diff --git a/src/domain/services/SettlementKeyPolicy.ts b/src/domain/services/SettlementKeyPolicy.ts new file mode 100644 index 0000000..0a4e9e3 --- /dev/null +++ b/src/domain/services/SettlementKeyPolicy.ts @@ -0,0 +1,35 @@ +export const UNSIGNED_SCROLLS_OVERRIDE_ENV = 'XYPH_ALLOW_UNSIGNED_SCROLLS'; + +export function allowUnsignedScrollsForSettlement( + env: NodeJS.ProcessEnv = process.env, +): boolean { + const override = env[UNSIGNED_SCROLLS_OVERRIDE_ENV]?.trim().toLowerCase(); + if (override === '1' || override === 'true') return true; + const vitest = env['VITEST']?.trim().toLowerCase(); + if (vitest && vitest !== '0' && vitest !== 'false') return true; + return env['NODE_ENV'] === 'test'; +} + +export function formatUnsignedScrollOverrideWarning(agentId: string): string { + return `No private key found for ${agentId} — unsigned scroll allowed because ${UNSIGNED_SCROLLS_OVERRIDE_ENV}=1 or test mode is enabled`; +} + +export function formatMissingSettlementKeyMessage( + agentId: string, + action: 'seal' | 'merge', +): string { + return `Missing private key for ${agentId}. Generate a Guild Seal key before '${action}' or set ${UNSIGNED_SCROLLS_OVERRIDE_ENV}=1 for dev/test only.`; +} + +export function missingSettlementKeyData( + agentId: string, + action: 'seal' | 'merge', +): Record { + return { + agentId, + action, + missing: 'guild-seal-private-key', + overrideEnvVar: UNSIGNED_SCROLLS_OVERRIDE_ENV, + hint: `Run 'xyph-actuator generate-key' before '${action}', or set ${UNSIGNED_SCROLLS_OVERRIDE_ENV}=1 for dev/test only.`, + }; +} diff --git a/src/domain/services/SubmissionService.ts b/src/domain/services/SubmissionService.ts index d32a7d3..70d1b07 100644 --- a/src/domain/services/SubmissionService.ts +++ b/src/domain/services/SubmissionService.ts @@ -182,7 +182,6 @@ export class SubmissionService { /** * Validates that a submission can be merged. * - Computed status must be APPROVED - * - Actor must be human * - Tip must be unique (no forked heads) unless explicit patchset specified */ async validateMerge( @@ -195,10 +194,8 @@ export class SubmissionService { `[MISSING_ARG] submission_id must start with 'submission:', got: '${submissionId}'` ); } - if (!this.isHumanPrincipal(actorId)) { - throw new Error( - `[FORBIDDEN] merge requires a human principal (human.*), got: '${actorId}'` - ); + if (!actorId || actorId.length === 0) { + throw new Error('[MISSING_ARG] actor_id must be non-empty'); } const questId = await this.read.getSubmissionQuestId(submissionId); diff --git a/test/unit/AgentActionService.test.ts b/test/unit/AgentActionService.test.ts index 6639bb5..f4e1c19 100644 --- a/test/unit/AgentActionService.test.ts +++ b/test/unit/AgentActionService.test.ts @@ -2,18 +2,31 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { Quest } from '../../src/domain/entities/Quest.js'; import type { GraphPort } from '../../src/ports/GraphPort.js'; import type { RoadmapQueryPort } from '../../src/ports/RoadmapPort.js'; +import type { EntityDetail } from '../../src/domain/models/dashboard.js'; import { AgentActionService } from '../../src/domain/services/AgentActionService.js'; const mocks = vi.hoisted(() => ({ createPatchSession: vi.fn(), validateSubmit: vi.fn(), validateReview: vi.fn(), + validateMerge: vi.fn(), submit: vi.fn(), review: vi.fn(), + decide: vi.fn(), getSubmissionForPatchset: vi.fn(), + getOpenSubmissionsForQuest: vi.fn(), + getPatchsetWorkspaceRef: vi.fn(), + getSubmissionQuestId: vi.fn(), + getQuestStatus: vi.fn(), getWorkspaceRef: vi.fn(), getHeadCommit: vi.fn(), getCommitsSince: vi.fn(), + isMerged: vi.fn(), + merge: vi.fn(), + fetchEntityDetail: vi.fn(), + hasPrivateKey: vi.fn(), + sign: vi.fn(), + payloadDigest: vi.fn(), })); vi.mock('../../src/infrastructure/helpers/createPatchSession.js', () => ({ @@ -29,9 +42,43 @@ vi.mock('../../src/domain/services/SubmissionService.js', () => ({ validateReview(patchsetId: string, actorId: string) { return mocks.validateReview(patchsetId, actorId); } + + validateMerge(submissionId: string, actorId: string, patchsetId?: string) { + return mocks.validateMerge(submissionId, actorId, patchsetId); + } + }, +})); + +vi.mock('../../src/domain/services/GuildSealService.js', () => ({ + GuildSealService: class GuildSealService { + hasPrivateKey(agentId: string) { + return mocks.hasPrivateKey(agentId); + } + + sign(payload: unknown, agentId: string) { + return mocks.sign(payload, agentId); + } + + payloadDigest(payload: unknown) { + return mocks.payloadDigest(payload); + } + }, +})); + +vi.mock('../../src/infrastructure/adapters/FsKeyringAdapter.js', () => ({ + FsKeyringAdapter: class FsKeyringAdapter { + readonly stub = true; }, })); +vi.mock('../../src/infrastructure/GraphContext.js', () => ({ + createGraphContext: () => ({ + fetchEntityDetail(id: string) { + return mocks.fetchEntityDetail(id); + }, + }), +})); + vi.mock('../../src/infrastructure/adapters/WarpSubmissionAdapter.js', () => ({ WarpSubmissionAdapter: class WarpSubmissionAdapter { submit(args: unknown) { @@ -42,9 +89,29 @@ vi.mock('../../src/infrastructure/adapters/WarpSubmissionAdapter.js', () => ({ return mocks.review(args); } + decide(args: unknown) { + return mocks.decide(args); + } + getSubmissionForPatchset(patchsetId: string) { return mocks.getSubmissionForPatchset(patchsetId); } + + getOpenSubmissionsForQuest(questId: string) { + return mocks.getOpenSubmissionsForQuest(questId); + } + + getPatchsetWorkspaceRef(patchsetId: string) { + return mocks.getPatchsetWorkspaceRef(patchsetId); + } + + getSubmissionQuestId(submissionId: string) { + return mocks.getSubmissionQuestId(submissionId); + } + + getQuestStatus(questId: string) { + return mocks.getQuestStatus(questId); + } }, })); @@ -61,6 +128,14 @@ vi.mock('../../src/infrastructure/adapters/GitWorkspaceAdapter.js', () => ({ getCommitsSince(base: string) { return mocks.getCommitsSince(base); } + + isMerged(ref: string, into: string) { + return mocks.isMerged(ref, into); + } + + merge(ref: string, into: string) { + return mocks.merge(ref, into); + } }, })); @@ -106,17 +181,82 @@ function makePatchSession() { }; } +function makeQuestDetail( + overrides?: Partial>, +): EntityDetail { + return { + id: 'task:AGT-001', + type: 'task', + props: {}, + outgoing: [], + incoming: [], + questDetail: { + id: 'task:AGT-001', + quest: { + id: 'task:AGT-001', + title: 'Agent kernel quest', + status: 'READY', + hours: 2, + taskKind: 'delivery', + computedCompletion: { + tracked: true, + complete: true, + verdict: 'SATISFIED', + requirementCount: 1, + criterionCount: 1, + coverageRatio: 1, + satisfiedCount: 1, + failingCriterionIds: [], + linkedOnlyCriterionIds: [], + missingCriterionIds: [], + policyId: 'policy:TRACE', + }, + }, + reviews: [], + decisions: [], + stories: [], + requirements: [], + criteria: [], + evidence: [], + policies: [{ + id: 'policy:TRACE', + campaignId: 'campaign:TRACE', + coverageThreshold: 1, + requireAllCriteria: true, + requireEvidence: true, + allowManualSeal: false, + }], + documents: [], + comments: [], + timeline: [], + ...overrides, + }, + }; +} + describe('AgentActionService', () => { beforeEach(() => { vi.clearAllMocks(); mocks.validateSubmit.mockResolvedValue(undefined); mocks.validateReview.mockResolvedValue(undefined); + mocks.validateMerge.mockResolvedValue({ tipPatchsetId: 'patchset:tip' }); mocks.submit.mockResolvedValue({ patchSha: 'patch:submit' }); mocks.review.mockResolvedValue({ patchSha: 'patch:review' }); + mocks.decide.mockResolvedValue({ patchSha: 'patch:merge' }); mocks.getSubmissionForPatchset.mockResolvedValue('submission:AGT-001'); + mocks.getOpenSubmissionsForQuest.mockResolvedValue([]); + mocks.getPatchsetWorkspaceRef.mockResolvedValue('feat/agent-action-kernel-v1'); + mocks.getSubmissionQuestId.mockResolvedValue('task:AGT-001'); + mocks.getQuestStatus.mockResolvedValue('READY'); mocks.getWorkspaceRef.mockResolvedValue('feat/agent-action-kernel-v1'); mocks.getHeadCommit.mockResolvedValue('abc123def456'); mocks.getCommitsSince.mockResolvedValue(['abc123def456']); + mocks.isMerged.mockResolvedValue(false); + mocks.merge.mockResolvedValue('mergecommit123456'); + mocks.fetchEntityDetail.mockResolvedValue(makeQuestDetail()); + mocks.hasPrivateKey.mockReturnValue(true); + mocks.sign.mockResolvedValue({ keyId: 'did:key:test', alg: 'ed25519' }); + mocks.payloadDigest.mockReturnValue('blake3:test'); }); it('rejects human-only actions with an explicit machine-readable reason', async () => { @@ -354,6 +494,170 @@ describe('AgentActionService', () => { expect(typeof outcome.details?.['authoredAt']).toBe('number'); }); + it('normalizes seal during dry-run when governed completion and key policy pass', async () => { + const service = new AgentActionService( + makeGraphPort({}), + makeRoadmap(makeQuest()), + 'agent.hal', + ); + + const outcome = await service.execute({ + kind: 'seal', + targetId: 'task:AGT-001', + dryRun: true, + args: { + artifactHash: 'blake3:artifact', + rationale: 'Governed work is complete and ready to seal.', + }, + }); + + expect(outcome).toMatchObject({ + kind: 'seal', + targetId: 'task:AGT-001', + allowed: true, + result: 'dry-run', + underlyingCommand: 'xyph seal task:AGT-001', + normalizedArgs: { + artifactHash: 'blake3:artifact', + rationale: 'Governed work is complete and ready to seal.', + }, + }); + }); + + it('executes seal by writing a scroll and marking the quest done', async () => { + const graph = { + patch: vi.fn(async (fn: (patch: { addNode: ReturnType; setProperty: ReturnType; addEdge: ReturnType }) => void) => { + const patch = { + addNode: vi.fn().mockReturnThis(), + setProperty: vi.fn().mockReturnThis(), + addEdge: vi.fn().mockReturnThis(), + }; + fn(patch); + return 'patch:seal'; + }), + }; + + const service = new AgentActionService( + makeGraphPort(graph), + makeRoadmap(makeQuest()), + 'agent.hal', + ); + + const outcome = await service.execute({ + kind: 'seal', + targetId: 'task:AGT-001', + args: { + artifactHash: 'blake3:artifact', + rationale: 'Governed work is complete and ready to seal.', + }, + }); + + expect(outcome).toMatchObject({ + kind: 'seal', + targetId: 'task:AGT-001', + allowed: true, + result: 'success', + patch: 'patch:seal', + details: { + id: 'task:AGT-001', + scrollId: 'artifact:task:AGT-001', + artifactHash: 'blake3:artifact', + rationale: 'Governed work is complete and ready to seal.', + sealedBy: 'agent.hal', + guildSeal: { keyId: 'did:key:test', alg: 'ed25519' }, + warnings: [], + }, + }); + }); + + it('normalizes merge during dry-run with settlement metadata', async () => { + const service = new AgentActionService( + makeGraphPort({}), + makeRoadmap(makeQuest()), + 'agent.hal', + ); + + const outcome = await service.execute({ + kind: 'merge', + targetId: 'submission:AGT-001', + dryRun: true, + args: { + rationale: 'Independent review is complete and the tip is approved.', + intoRef: 'main', + }, + }); + + expect(mocks.validateMerge).toHaveBeenCalledWith('submission:AGT-001', 'agent.hal', undefined); + expect(outcome).toMatchObject({ + kind: 'merge', + targetId: 'submission:AGT-001', + allowed: true, + result: 'dry-run', + normalizedArgs: { + rationale: 'Independent review is complete and the tip is approved.', + intoRef: 'main', + tipPatchsetId: 'patchset:tip', + questId: 'task:AGT-001', + shouldAutoSeal: true, + workspaceRef: 'feat/agent-action-kernel-v1', + }, + }); + }); + + it('executes merge by settling the workspace and writing the merge decision', async () => { + const graph = { + patch: vi.fn(async (fn: (patch: { addNode: ReturnType; setProperty: ReturnType; addEdge: ReturnType }) => void) => { + const patch = { + addNode: vi.fn().mockReturnThis(), + setProperty: vi.fn().mockReturnThis(), + addEdge: vi.fn().mockReturnThis(), + }; + fn(patch); + return 'patch:scroll'; + }), + }; + + const service = new AgentActionService( + makeGraphPort(graph), + makeRoadmap(makeQuest()), + 'agent.hal', + ); + + const outcome = await service.execute({ + kind: 'merge', + targetId: 'submission:AGT-001', + args: { + rationale: 'Independent review is complete and the tip is approved.', + intoRef: 'main', + }, + }); + + expect(mocks.merge).toHaveBeenCalledWith('feat/agent-action-kernel-v1', 'main'); + expect(mocks.decide).toHaveBeenCalledWith(expect.objectContaining({ + submissionId: 'submission:AGT-001', + kind: 'merge', + rationale: 'Independent review is complete and the tip is approved.', + mergeCommit: 'mergecommit123456', + })); + expect(outcome).toMatchObject({ + kind: 'merge', + targetId: 'submission:AGT-001', + allowed: true, + result: 'success', + patch: 'patch:merge', + details: { + submissionId: 'submission:AGT-001', + questId: 'task:AGT-001', + mergeCommit: 'mergecommit123456', + alreadyMerged: false, + autoSealed: true, + guildSeal: { keyId: 'did:key:test', alg: 'ed25519' }, + warnings: [], + }, + }); + expect(typeof outcome.details?.['decisionId']).toBe('string'); + }); + it('normalizes submit during dry-run with workspace metadata and generated ids', async () => { const service = new AgentActionService( makeGraphPort({}), diff --git a/test/unit/AgentCommands.test.ts b/test/unit/AgentCommands.test.ts index 3207391..0b3cca5 100644 --- a/test/unit/AgentCommands.test.ts +++ b/test/unit/AgentCommands.test.ts @@ -350,7 +350,7 @@ describe('agent act command', () => { kind: 'merge', targetId: 'submission:OWN-001', reason: 'Submission is approved and ready for settlement.', - supportedByActionKernel: false, + supportedByActionKernel: true, }, }, ], @@ -404,7 +404,7 @@ describe('agent act command', () => { kind: 'merge', targetId: 'submission:OWN-001', reason: 'Submission is approved and ready for settlement.', - supportedByActionKernel: false, + supportedByActionKernel: true, }, }, ], @@ -452,7 +452,7 @@ describe('agent act command', () => { kind: 'merge', targetId: 'submission:OWN-001', reason: 'Submission is approved and ready for settlement.', - supportedByActionKernel: false, + supportedByActionKernel: true, }, }, ], @@ -506,7 +506,7 @@ describe('agent act command', () => { kind: 'merge', targetId: 'submission:OWN-001', reason: 'Submission is approved and ready for settlement.', - supportedByActionKernel: false, + supportedByActionKernel: true, }, }, ], @@ -791,6 +791,101 @@ describe('agent act command', () => { }); }); + it('maps seal options into normalized action args', async () => { + mocks.execute.mockResolvedValue({ + kind: 'seal', + targetId: 'task:AGT-001', + allowed: true, + dryRun: true, + requiresHumanApproval: false, + validation: { + valid: true, + code: null, + reasons: [], + }, + normalizedArgs: {}, + underlyingCommand: 'xyph seal task:AGT-001', + sideEffects: [], + result: 'dry-run', + patch: null, + details: null, + }); + + const ctx = makeCtx(); + const program = new Command(); + registerAgentCommands(program, ctx); + + await program.parseAsync([ + 'act', + 'seal', + 'task:AGT-001', + '--artifact', + 'blake3:artifact', + '--rationale', + 'Governed work is complete and ready to seal.', + '--dry-run', + ], { from: 'user' }); + + expect(mocks.execute).toHaveBeenCalledWith({ + kind: 'seal', + targetId: 'task:AGT-001', + dryRun: true, + args: { + artifactHash: 'blake3:artifact', + rationale: 'Governed work is complete and ready to seal.', + }, + }); + }); + + it('maps merge options into normalized action args', async () => { + mocks.execute.mockResolvedValue({ + kind: 'merge', + targetId: 'submission:AGT-001', + allowed: true, + dryRun: true, + requiresHumanApproval: false, + validation: { + valid: true, + code: null, + reasons: [], + }, + normalizedArgs: {}, + underlyingCommand: 'xyph merge submission:AGT-001', + sideEffects: [], + result: 'dry-run', + patch: null, + details: null, + }); + + const ctx = makeCtx(); + const program = new Command(); + registerAgentCommands(program, ctx); + + await program.parseAsync([ + 'act', + 'merge', + 'submission:AGT-001', + '--rationale', + 'Independent review is complete and the tip is approved.', + '--into', + 'main', + '--patchset', + 'patchset:AGT-001', + '--dry-run', + ], { from: 'user' }); + + expect(mocks.execute).toHaveBeenCalledWith({ + kind: 'merge', + targetId: 'submission:AGT-001', + dryRun: true, + args: { + rationale: 'Independent review is complete and the tip is approved.', + intoRef: 'main', + patchsetId: 'patchset:AGT-001', + }, + }); + }); + it('emits the specialized handoff JSON envelope', async () => { mocks.execute.mockResolvedValue({ kind: 'handoff', diff --git a/test/unit/AgentSubmissionService.test.ts b/test/unit/AgentSubmissionService.test.ts index 5d6b1b5..4ecd0c8 100644 --- a/test/unit/AgentSubmissionService.test.ts +++ b/test/unit/AgentSubmissionService.test.ts @@ -163,6 +163,7 @@ describe('AgentSubmissionService', () => { nextStep: { kind: 'merge', targetId: 'submission:OWN-001', + supportedByActionKernel: true, }, }); }); diff --git a/test/unit/SubmissionService.test.ts b/test/unit/SubmissionService.test.ts index 0522c0b..e262e41 100644 --- a/test/unit/SubmissionService.test.ts +++ b/test/unit/SubmissionService.test.ts @@ -220,11 +220,26 @@ describe('SubmissionService.validateMerge', () => { expect(result.tipPatchsetId).toBe('patchset:S1:P1'); }); - it('throws [FORBIDDEN] for non-human actor', async () => { - const svc = new SubmissionService(makeReadModel()); + it('allows an agent principal to merge once the submission is independently approved', async () => { + const approveReview: ReviewRef = { + id: 'r1', + verdict: 'approve', + reviewedBy: 'human.alice', + reviewedAt: 200, + }; + const tipPatchset: PatchsetRef = { + id: 'patchset:S1:P1', + authoredAt: 100, + }; + const svc = new SubmissionService( + makeReadModel({ + getPatchsetRefs: vi.fn().mockResolvedValue([tipPatchset]), + getReviewsForPatchset: vi.fn().mockResolvedValue([approveReview]), + }), + ); await expect( svc.validateMerge('submission:S1', 'agent.claude'), - ).rejects.toThrow('[FORBIDDEN]'); + ).resolves.toEqual({ tipPatchsetId: 'patchset:S1:P1' }); }); it('throws [INVALID_FROM] when submission is not APPROVED', async () => { From 9708d89a8f57cbe30f93b87fa04719a1479bb718 Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 12 Mar 2026 20:02:33 -0700 Subject: [PATCH 13/22] Fix source xyph entrypoint resolution --- src/cli/runtimeEntry.ts | 76 ++++++++++++++++++++++++++++++++++ test/unit/runtimeEntry.test.ts | 53 ++++++++++++++++++++++++ xyph.ts | 68 +++++++++++++++++++++--------- 3 files changed, 177 insertions(+), 20 deletions(-) create mode 100644 src/cli/runtimeEntry.ts create mode 100644 test/unit/runtimeEntry.test.ts diff --git a/src/cli/runtimeEntry.ts b/src/cli/runtimeEntry.ts new file mode 100644 index 0000000..82c80ee --- /dev/null +++ b/src/cli/runtimeEntry.ts @@ -0,0 +1,76 @@ +import { existsSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +export interface ImportLaunchPlan { + kind: 'import'; + moduleUrl: string; +} + +export interface TsxLaunchPlan { + kind: 'tsx'; + scriptPath: string; +} + +export type RuntimeLaunchPlan = ImportLaunchPlan | TsxLaunchPlan; + +export function stripTuiFlag(argv: readonly string[]): string[] { + return argv.filter((arg) => arg !== '--tui'); +} + +export function countCommandArgs(argv: readonly string[]): number { + let count = 0; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === '--tui') continue; + if (arg === '--as') { + i += 1; + continue; + } + if (typeof arg === 'string' && arg.startsWith('--as=')) continue; + count += 1; + } + + return count; +} + +export function shouldLaunchTui(argv: readonly string[]): boolean { + return argv.includes('--tui') || countCommandArgs(argv) === 0; +} + +export function resolveRuntimeLaunchPlan( + baseDir: string, + stem: 'xyph-actuator' | 'xyph-dashboard', + fileExists: (path: string) => boolean = existsSync, +): RuntimeLaunchPlan { + const jsPath = resolve(baseDir, `${stem}.js`); + if (fileExists(jsPath)) { + return { + kind: 'import', + moduleUrl: pathToFileURL(jsPath).href, + }; + } + + const tsPath = resolve(baseDir, `${stem}.ts`); + if (fileExists(tsPath)) { + return { + kind: 'tsx', + scriptPath: tsPath, + }; + } + + throw new Error(`Could not resolve runtime entry for ${stem} in ${baseDir}`); +} + +export function resolveLocalTsxCliPath( + baseDir: string, + fileExists: (path: string) => boolean = existsSync, +): string { + const cliPath = resolve(baseDir, 'node_modules', 'tsx', 'dist', 'cli.mjs'); + if (fileExists(cliPath)) { + return cliPath; + } + + throw new Error(`Could not resolve local tsx CLI from ${baseDir}`); +} diff --git a/test/unit/runtimeEntry.test.ts b/test/unit/runtimeEntry.test.ts new file mode 100644 index 0000000..386effd --- /dev/null +++ b/test/unit/runtimeEntry.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { + countCommandArgs, + resolveLocalTsxCliPath, + resolveRuntimeLaunchPlan, + shouldLaunchTui, + stripTuiFlag, +} from '../../src/cli/runtimeEntry.js'; + +describe('runtimeEntry', () => { + it('strips the tui flag from forwarded argv', () => { + expect(stripTuiFlag(['status', '--tui', '--json'])).toEqual(['status', '--json']); + }); + + it('counts real command args while ignoring tui and identity override flags', () => { + expect(countCommandArgs(['--tui', '--as', 'agent.hal'])).toBe(0); + expect(countCommandArgs(['status', '--as=agent.hal', '--json'])).toBe(2); + }); + + it('launches TUI when explicitly requested or when no command args remain', () => { + expect(shouldLaunchTui(['--tui', 'status'])).toBe(true); + expect(shouldLaunchTui(['--as', 'agent.hal'])).toBe(true); + expect(shouldLaunchTui(['status', '--json'])).toBe(false); + }); + + it('resolves built js entrypoints before falling back to source ts entrypoints', () => { + const existing = new Set([ + '/repo/xyph-dashboard.js', + '/repo/xyph-actuator.ts', + ]); + const has = (path: string) => existing.has(path); + + expect(resolveRuntimeLaunchPlan('/repo', 'xyph-dashboard', has)).toEqual({ + kind: 'import', + moduleUrl: 'file:///repo/xyph-dashboard.js', + }); + expect(resolveRuntimeLaunchPlan('/repo', 'xyph-actuator', has)).toEqual({ + kind: 'tsx', + scriptPath: '/repo/xyph-actuator.ts', + }); + }); + + it('throws when neither built nor source entrypoints exist', () => { + expect(() => resolveRuntimeLaunchPlan('/repo', 'xyph-actuator', () => false)) + .toThrow('Could not resolve runtime entry for xyph-actuator in /repo'); + }); + + it('resolves the local tsx cli for source-mode wrapper launches', () => { + const existing = new Set(['/repo/node_modules/tsx/dist/cli.mjs']); + expect(resolveLocalTsxCliPath('/repo', (path) => existing.has(path))) + .toBe('/repo/node_modules/tsx/dist/cli.mjs'); + }); +}); diff --git a/xyph.ts b/xyph.ts index ac74cd3..a72db9e 100644 --- a/xyph.ts +++ b/xyph.ts @@ -1,33 +1,61 @@ #!/usr/bin/env node +import { existsSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; -function stripTuiFlag(argv: readonly string[]): string[] { - return argv.filter((arg) => arg !== '--tui'); -} - -function countCommandArgs(argv: readonly string[]): number { - let count = 0; +function resolveHelperModuleUrl(baseDir: string): string { + const builtPath = resolve(baseDir, 'src/cli/runtimeEntry.js'); + if (existsSync(builtPath)) { + return pathToFileURL(builtPath).href; + } - for (let i = 0; i < argv.length; i += 1) { - const arg = argv[i]; - if (arg === '--tui') continue; - if (arg === '--as') { - i += 1; - continue; - } - if (typeof arg === 'string' && arg.startsWith('--as=')) continue; - count += 1; + const sourcePath = resolve(baseDir, 'src/cli/runtimeEntry.ts'); + if (existsSync(sourcePath)) { + return pathToFileURL(sourcePath).href; } - return count; + throw new Error(`Could not resolve runtimeEntry helper from ${baseDir}`); } const argv = process.argv.slice(2); +const runtimeDir = dirname(fileURLToPath(import.meta.url)); +const { + resolveLocalTsxCliPath, + resolveRuntimeLaunchPlan, + shouldLaunchTui, + stripTuiFlag, +} = await import(resolveHelperModuleUrl(runtimeDir)); const forwardedArgs = stripTuiFlag(argv); -const shouldLaunchTui = argv.includes('--tui') || countCommandArgs(argv) === 0; -if (shouldLaunchTui) { +const launchTui = shouldLaunchTui(argv); +const launchPlan = resolveRuntimeLaunchPlan( + runtimeDir, + launchTui ? 'xyph-dashboard' : 'xyph-actuator', +); + +if (launchPlan.kind === 'tsx') { + const tsxCli = resolveLocalTsxCliPath(runtimeDir); + const child = spawnSync( + process.execPath, + [ + tsxCli, + launchPlan.scriptPath, + ...(launchTui ? forwardedArgs : argv), + ], + { + stdio: 'inherit', + }, + ); + if (child.error) { + throw child.error; + } + process.exit(child.status ?? 1); +} + +if (launchTui) { process.argv = [process.argv[0] ?? 'node', process.argv[1] ?? 'xyph', ...forwardedArgs]; - await import('./xyph-dashboard.js'); + await import(launchPlan.moduleUrl); } else { - await import('./xyph-actuator.js'); + await import(launchPlan.moduleUrl); } From da3c9d30aaada8a028c73d2ee3aa83ae84ae94ec Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 13 Mar 2026 11:34:33 -0700 Subject: [PATCH 14/22] Tighten agent settlement and review queues --- docs/canonical/AGENT_PROTOCOL.md | 7 + src/cli/commands/agent.ts | 8 +- src/domain/services/AgentActionService.ts | 176 ++++++++++++------ src/domain/services/AgentBriefingService.ts | 14 +- src/domain/services/AgentSubmissionService.ts | 88 +++++---- test/unit/AgentActionService.test.ts | 133 +++++++++++++ test/unit/AgentBriefingService.test.ts | 61 ++++++ test/unit/AgentSubmissionService.test.ts | 31 +++ 8 files changed, 418 insertions(+), 100 deletions(-) diff --git a/docs/canonical/AGENT_PROTOCOL.md b/docs/canonical/AGENT_PROTOCOL.md index c286418..1d794ca 100644 --- a/docs/canonical/AGENT_PROTOCOL.md +++ b/docs/canonical/AGENT_PROTOCOL.md @@ -161,6 +161,9 @@ The `--json` result must include: `validation` must contain machine-readable failure reasons when the action is rejected. Rejections must happen **before** any graph or workspace mutation. +If a follow-on step fails after mutation has already been committed, the +outcome must stay truthful by returning success plus `warnings` and structured +`partialFailure` data instead of pretending nothing happened. ### 4.5 `handoff --json` @@ -194,6 +197,10 @@ Checkpoint-2 action kinds are: These are the routine agent actions that should be executable through `act` in the checkpoint-2 kernel. +`seal` is review-gated in the agent kernel. An agent may only seal a quest when +the latest linked submission is independently approved; `seal` must not bypass +the submission review loop. + The current runtime now ships that routine action set: - `claim` diff --git a/src/cli/commands/agent.ts b/src/cli/commands/agent.ts index f85cf47..d6a6876 100644 --- a/src/cli/commands/agent.ts +++ b/src/cli/commands/agent.ts @@ -203,7 +203,12 @@ function renderAgentContext( function renderBriefing(briefing: { identity: { agentId: string; principalType: string }; assignments: { quest: { id: string; title: string; status: string }; nextAction: AgentActionCandidate | null }[]; - reviewQueue: { submissionId: string; questTitle: string; status: string }[]; + reviewQueue: { + submissionId: string; + questTitle: string; + status: string; + nextStep: { kind: string; targetId: string }; + }[]; frontier: { quest: { id: string; title: string; status: string }; nextAction: AgentActionCandidate | null }[]; recentHandoffs: { noteId: string; title: string; authoredAt: number; relatedIds: string[] }[]; alerts: { severity: string; message: string }[]; @@ -232,6 +237,7 @@ function renderBriefing(briefing: { } else { for (const entry of briefing.reviewQueue) { lines.push(` - ${entry.submissionId} ${entry.questTitle} [${entry.status}]`); + lines.push(` next: ${entry.nextStep.kind} ${entry.nextStep.targetId}`); } } diff --git a/src/domain/services/AgentActionService.ts b/src/domain/services/AgentActionService.ts index c51112e..f1b8ee4 100644 --- a/src/domain/services/AgentActionService.ts +++ b/src/domain/services/AgentActionService.ts @@ -987,6 +987,42 @@ export class AgentActionValidator { }); } + const submission = detail?.questDetail?.submission; + if (!submission) { + return failAssessment(request, 'approved-submission-required', [ + `seal requires an independently approved submission for ${request.targetId}; no submission is linked to this quest.`, + ], { + normalizedArgs: { + artifactHash, + rationale, + }, + underlyingCommand: `xyph seal ${request.targetId}`, + sideEffects: [ + `create artifact:${request.targetId}`, + 'status -> DONE', + 'completed_at -> now', + ], + }); + } + if (submission.status !== 'APPROVED') { + return failAssessment(request, 'approved-submission-required', [ + `seal requires an independently approved submission for ${request.targetId}; latest submission ${submission.id} is ${submission.status}.`, + ], { + normalizedArgs: { + artifactHash, + rationale, + submissionId: submission.id, + submissionStatus: submission.status, + }, + underlyingCommand: `xyph seal ${request.targetId}`, + sideEffects: [ + `create artifact:${request.targetId}`, + 'status -> DONE', + 'completed_at -> now', + ], + }); + } + const keyring = new FsKeyringAdapter(); const sealService = new GuildSealService(keyring); if (!sealService.hasPrivateKey(this.agentId) && !allowUnsignedScrollsForSettlement()) { @@ -1574,17 +1610,6 @@ export class AgentActionService { const sealService = new GuildSealService(keyring); const allowUnsignedScrolls = allowUnsignedScrollsForSettlement(); - let openSubWarning: string | undefined; - try { - const adapter = new WarpSubmissionAdapter(this.graphPort, this.agentId); - const openSubs = await adapter.getOpenSubmissionsForQuest(action.targetId); - if (openSubs.length > 0) { - openSubWarning = `Quest ${action.targetId} has open submission ${openSubs[0]}. Consider using 'merge' instead.`; - } - } catch { - // Non-fatal: preserve raw seal behavior. - } - if (!sealService.hasPrivateKey(this.agentId) && !allowUnsignedScrolls) { return { ...assessment, @@ -1633,7 +1658,6 @@ export class AgentActionService { }); const warnings: string[] = []; - if (openSubWarning) warnings.push(openSubWarning); if (!guildSeal) warnings.push(formatUnsignedScrollOverrideWarning(this.agentId)); return { @@ -1676,61 +1700,100 @@ export class AgentActionService { } const decisionId = autoId('decision:'); - const { patchSha } = await adapter.decide({ - submissionId: action.targetId, - decisionId, - kind: 'merge', - rationale: action.rationale, - mergeCommit, - }); + let patchSha: string | null = null; + try { + const decision = await adapter.decide({ + submissionId: action.targetId, + decisionId, + kind: 'merge', + rationale: action.rationale, + mergeCommit, + }); + patchSha = decision.patchSha; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { + ...assessment, + result: 'success', + patch: null, + details: { + submissionId: action.targetId, + decisionId, + questId: action.questId ?? null, + mergeCommit: mergeCommit ?? null, + alreadyMerged, + autoSealed: false, + guildSeal: null, + warnings: [ + `Merge committed to ${action.intoRef}, but the merge decision could not be recorded: ${msg}`, + ], + partialFailure: { + stage: 'record-decision', + message: msg, + }, + }, + }; + } let autoSealed = false; let guildSealInfo: { keyId: string; alg: string } | null = null; let unsignedScrollWarning: string | null = null; + let partialFailure: { stage: string; message: string } | null = null; if (action.questId && action.shouldAutoSeal) { - const now = Date.now(); - const keyring = new FsKeyringAdapter(); - const sealService = new GuildSealService(keyring); - const scrollPayload = { - artifactHash: mergeCommit ?? 'unknown', - questId: action.questId, - rationale: action.rationale, - sealedBy: this.agentId, - sealedAt: now, - }; - const guildSeal = await sealService.sign(scrollPayload, this.agentId); - - const sealGraph = await this.graphPort.getGraph(); - const scrollId = `artifact:${action.questId}`; - await sealGraph.patch((p) => { - p.addNode(scrollId) - .setProperty(scrollId, 'artifact_hash', mergeCommit ?? 'unknown') - .setProperty(scrollId, 'rationale', action.rationale) - .setProperty(scrollId, 'type', 'scroll') - .setProperty(scrollId, 'sealed_by', this.agentId) - .setProperty(scrollId, 'sealed_at', now) - .setProperty(scrollId, 'payload_digest', sealService.payloadDigest(scrollPayload)) - .addEdge(scrollId, action.questId as string, 'fulfills'); - - if (guildSeal) { - p.setProperty(scrollId, 'guild_seal_alg', guildSeal.alg) - .setProperty(scrollId, 'guild_seal_key_id', guildSeal.keyId) - .setProperty(scrollId, 'guild_seal_sig', guildSeal.sig); - } - - p.setProperty(action.questId as string, 'status', 'DONE') - .setProperty(action.questId as string, 'completed_at', now); - }); + try { + const now = Date.now(); + const keyring = new FsKeyringAdapter(); + const sealService = new GuildSealService(keyring); + const scrollPayload = { + artifactHash: mergeCommit ?? 'unknown', + questId: action.questId, + rationale: action.rationale, + sealedBy: this.agentId, + sealedAt: now, + }; + const guildSeal = await sealService.sign(scrollPayload, this.agentId); + + const sealGraph = await this.graphPort.getGraph(); + const scrollId = `artifact:${action.questId}`; + await sealGraph.patch((p) => { + p.addNode(scrollId) + .setProperty(scrollId, 'artifact_hash', mergeCommit ?? 'unknown') + .setProperty(scrollId, 'rationale', action.rationale) + .setProperty(scrollId, 'type', 'scroll') + .setProperty(scrollId, 'sealed_by', this.agentId) + .setProperty(scrollId, 'sealed_at', now) + .setProperty(scrollId, 'payload_digest', sealService.payloadDigest(scrollPayload)) + .addEdge(scrollId, action.questId as string, 'fulfills'); + + if (guildSeal) { + p.setProperty(scrollId, 'guild_seal_alg', guildSeal.alg) + .setProperty(scrollId, 'guild_seal_key_id', guildSeal.keyId) + .setProperty(scrollId, 'guild_seal_sig', guildSeal.sig); + } + + p.setProperty(action.questId as string, 'status', 'DONE') + .setProperty(action.questId as string, 'completed_at', now); + }); - autoSealed = true; - if (guildSeal) guildSealInfo = { keyId: guildSeal.keyId, alg: guildSeal.alg }; - if (!guildSeal) { - unsignedScrollWarning = formatUnsignedScrollOverrideWarning(this.agentId); + autoSealed = true; + if (guildSeal) guildSealInfo = { keyId: guildSeal.keyId, alg: guildSeal.alg }; + if (!guildSeal) { + unsignedScrollWarning = formatUnsignedScrollOverrideWarning(this.agentId); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + partialFailure = { + stage: 'auto-seal', + message: msg, + }; } } const warnings: string[] = []; if (unsignedScrollWarning) warnings.push(unsignedScrollWarning); + if (partialFailure) { + warnings.push(`Merge was recorded, but follow-on auto-seal failed: ${partialFailure.message}`); + } return { ...assessment, @@ -1745,6 +1808,7 @@ export class AgentActionService { autoSealed, guildSeal: guildSealInfo, warnings, + partialFailure, }, }; } diff --git a/src/domain/services/AgentBriefingService.ts b/src/domain/services/AgentBriefingService.ts index 0686744..424c23a 100644 --- a/src/domain/services/AgentBriefingService.ts +++ b/src/domain/services/AgentBriefingService.ts @@ -6,6 +6,11 @@ import { createGraphContext } from '../../infrastructure/GraphContext.js'; import { toNeighborEntries } from '../../infrastructure/helpers/isNeighborEntry.js'; import { ReadinessService } from './ReadinessService.js'; import { AgentActionValidator } from './AgentActionService.js'; +import { + determineSubmissionNextStep, + isReviewableByAgent, + type AgentSubmissionNextStep, +} from './AgentSubmissionService.js'; import { AgentRecommender, type AgentActionCandidate, @@ -55,6 +60,7 @@ export interface AgentReviewQueueEntry { submittedBy: string; submittedAt: number; reason: string; + nextStep: AgentSubmissionNextStep; } export interface AgentHandoffSummary { @@ -216,8 +222,7 @@ export class AgentBriefingService { const questById = new Map(snapshot.quests.map((quest) => [quest.id, quest] as const)); const queue = snapshot.submissions .filter((submission) => - (submission.status === 'OPEN' || submission.status === 'CHANGES_REQUESTED') && - submission.submittedBy !== this.agentId, + isReviewableByAgent(submission, this.agentId), ) .map((submission) => { const quest = questById.get(submission.questId); @@ -228,9 +233,8 @@ export class AgentBriefingService { status: submission.status, submittedBy: submission.submittedBy, submittedAt: submission.submittedAt, - reason: submission.status === 'CHANGES_REQUESTED' - ? 'Needs another review pass after requested changes.' - : 'Open submission awaiting review.', + reason: 'Open submission awaiting review.', + nextStep: determineSubmissionNextStep(submission, this.agentId), } satisfies AgentReviewQueueEntry; }); diff --git a/src/domain/services/AgentSubmissionService.ts b/src/domain/services/AgentSubmissionService.ts index 856396a..f60689e 100644 --- a/src/domain/services/AgentSubmissionService.ts +++ b/src/domain/services/AgentSubmissionService.ts @@ -60,10 +60,10 @@ function isTerminalSubmission(status: SubmissionStatus): boolean { return status === 'MERGED' || status === 'CLOSED'; } -function isReviewableByAgent(submission: SubmissionNode, agentId: string): boolean { +export function isReviewableByAgent(submission: SubmissionNode, agentId: string): boolean { return ( submission.submittedBy !== agentId && - (submission.status === 'OPEN' || submission.status === 'CHANGES_REQUESTED') + submission.status === 'OPEN' ); } @@ -180,52 +180,64 @@ export class AgentSubmissionService { stale, attentionCodes, contextId: submission.questId, - nextStep: this.determineNextStep(submission), + nextStep: determineSubmissionNextStep(submission, this.agentId), }; } +} - private determineNextStep(submission: SubmissionNode): AgentSubmissionNextStep { - if (isReviewableByAgent(submission, this.agentId)) { - return { - kind: 'review', - targetId: submission.tipPatchsetId ?? submission.id, - reason: 'Review the current tip patchset for this submission.', - supportedByActionKernel: true, - }; - } - - if (submission.submittedBy === this.agentId && submission.status === 'CHANGES_REQUESTED') { - return { - kind: 'revise', - targetId: submission.id, - reason: 'Address requested changes with a new patchset revision.', - supportedByActionKernel: false, - }; - } +export function determineSubmissionNextStep( + submission: SubmissionNode, + agentId: string, +): AgentSubmissionNextStep { + if (isReviewableByAgent(submission, agentId)) { + return { + kind: 'review', + targetId: submission.tipPatchsetId ?? submission.id, + reason: 'Review the current tip patchset for this submission.', + supportedByActionKernel: typeof submission.tipPatchsetId === 'string', + }; + } - if (submission.submittedBy === this.agentId && submission.status === 'APPROVED') { - return { - kind: 'merge', - targetId: submission.id, - reason: 'Submission is approved and ready for settlement.', - supportedByActionKernel: true, - }; - } + if (submission.submittedBy === agentId && submission.status === 'CHANGES_REQUESTED') { + return { + kind: 'revise', + targetId: submission.id, + reason: 'Address requested changes with a new patchset revision.', + supportedByActionKernel: false, + }; + } - if (submission.submittedBy === this.agentId) { - return { - kind: 'wait', - targetId: submission.questId, - reason: 'Submission is awaiting external review or follow-up.', - supportedByActionKernel: false, - }; - } + if (submission.submittedBy === agentId && submission.status === 'APPROVED') { + return { + kind: 'merge', + targetId: submission.id, + reason: 'Submission is approved and ready for settlement.', + supportedByActionKernel: true, + }; + } + if (submission.status === 'CHANGES_REQUESTED') { return { kind: 'inspect', targetId: submission.questId, - reason: 'Inspect the quest context before taking a follow-on action.', + reason: 'The current tip is blocked by requested changes; wait for the submitter to revise before reviewing again.', supportedByActionKernel: false, }; } + + if (submission.submittedBy === agentId) { + return { + kind: 'wait', + targetId: submission.questId, + reason: 'Submission is awaiting external review or follow-up.', + supportedByActionKernel: false, + }; + } + + return { + kind: 'inspect', + targetId: submission.questId, + reason: 'Inspect the quest context before taking a follow-on action.', + supportedByActionKernel: false, + }; } diff --git a/test/unit/AgentActionService.test.ts b/test/unit/AgentActionService.test.ts index f4e1c19..2f786df 100644 --- a/test/unit/AgentActionService.test.ts +++ b/test/unit/AgentActionService.test.ts @@ -212,6 +212,16 @@ function makeQuestDetail( policyId: 'policy:TRACE', }, }, + submission: { + id: 'submission:AGT-001', + questId: 'task:AGT-001', + status: 'APPROVED', + tipPatchsetId: 'patchset:tip', + headsCount: 1, + approvalCount: 1, + submittedBy: 'agent.other', + submittedAt: Date.UTC(2026, 2, 12, 18, 0, 0), + }, reviews: [], decisions: [], stories: [], @@ -524,6 +534,49 @@ describe('AgentActionService', () => { }); }); + it('rejects seal when the quest lacks an independently approved submission', async () => { + mocks.fetchEntityDetail.mockResolvedValue(makeQuestDetail({ + submission: { + id: 'submission:AGT-001', + questId: 'task:AGT-001', + status: 'OPEN', + tipPatchsetId: 'patchset:tip', + headsCount: 1, + approvalCount: 0, + submittedBy: 'agent.hal', + submittedAt: Date.UTC(2026, 2, 12, 18, 0, 0), + }, + })); + + const service = new AgentActionService( + makeGraphPort({}), + makeRoadmap(makeQuest()), + 'agent.hal', + ); + + const outcome = await service.execute({ + kind: 'seal', + targetId: 'task:AGT-001', + dryRun: true, + args: { + artifactHash: 'blake3:artifact', + rationale: 'Attempting to settle without independent approval.', + }, + }); + + expect(outcome).toMatchObject({ + kind: 'seal', + targetId: 'task:AGT-001', + allowed: false, + result: 'rejected', + validation: { + valid: false, + code: 'approved-submission-required', + }, + }); + expect(outcome.validation.reasons[0]).toContain('latest submission submission:AGT-001 is OPEN'); + }); + it('executes seal by writing a scroll and marking the quest done', async () => { const graph = { patch: vi.fn(async (fn: (patch: { addNode: ReturnType; setProperty: ReturnType; addEdge: ReturnType }) => void) => { @@ -658,6 +711,86 @@ describe('AgentActionService', () => { expect(typeof outcome.details?.['decisionId']).toBe('string'); }); + it('reports committed merge state instead of rejection when recording the decision fails', async () => { + mocks.decide.mockRejectedValue(new Error('graph write failed')); + + const service = new AgentActionService( + makeGraphPort({}), + makeRoadmap(makeQuest()), + 'agent.hal', + ); + + const outcome = await service.execute({ + kind: 'merge', + targetId: 'submission:AGT-001', + args: { + rationale: 'Independent review is complete and the tip is approved.', + intoRef: 'main', + }, + }); + + expect(mocks.merge).toHaveBeenCalledWith('feat/agent-action-kernel-v1', 'main'); + expect(outcome).toMatchObject({ + kind: 'merge', + targetId: 'submission:AGT-001', + allowed: true, + result: 'success', + patch: null, + details: { + submissionId: 'submission:AGT-001', + mergeCommit: 'mergecommit123456', + alreadyMerged: false, + autoSealed: false, + partialFailure: { + stage: 'record-decision', + message: 'graph write failed', + }, + }, + }); + }); + + it('reports auto-seal failure as a warning after the merge decision is recorded', async () => { + const graph = { + patch: vi.fn(async () => { + throw new Error('artifact node already exists'); + }), + }; + + const service = new AgentActionService( + makeGraphPort(graph), + makeRoadmap(makeQuest()), + 'agent.hal', + ); + + const outcome = await service.execute({ + kind: 'merge', + targetId: 'submission:AGT-001', + args: { + rationale: 'Independent review is complete and the tip is approved.', + intoRef: 'main', + }, + }); + + expect(outcome).toMatchObject({ + kind: 'merge', + targetId: 'submission:AGT-001', + allowed: true, + result: 'success', + patch: 'patch:merge', + details: { + submissionId: 'submission:AGT-001', + autoSealed: false, + partialFailure: { + stage: 'auto-seal', + message: 'artifact node already exists', + }, + }, + }); + expect(outcome.details?.['warnings']).toEqual([ + 'Merge was recorded, but follow-on auto-seal failed: artifact node already exists', + ]); + }); + it('normalizes submit during dry-run with workspace metadata and generated ids', async () => { const service = new AgentActionService( makeGraphPort({}), diff --git a/test/unit/AgentBriefingService.test.ts b/test/unit/AgentBriefingService.test.ts index 98f0a56..a1c5764 100644 --- a/test/unit/AgentBriefingService.test.ts +++ b/test/unit/AgentBriefingService.test.ts @@ -95,6 +95,7 @@ describe('AgentBriefingService', () => { status: 'OPEN', submittedBy: 'agent.other', submittedAt: 100, + tipPatchsetId: 'patchset:AGT-001', }), ], sortedTaskIds: ['task:AGT-001', 'task:AGT-002'], @@ -196,6 +197,11 @@ describe('AgentBriefingService', () => { submissionId: 'submission:AGT-001', questId: 'task:AGT-002', status: 'OPEN', + nextStep: { + kind: 'review', + targetId: 'patchset:AGT-001', + supportedByActionKernel: true, + }, }, ]); expect(briefing.recentHandoffs).toEqual([ @@ -312,4 +318,59 @@ describe('AgentBriefingService', () => { source: 'planning', }); }); + + it('omits CHANGES_REQUESTED submissions from the briefing review queue', async () => { + const snapshot = makeSnapshot({ + quests: [ + quest({ + id: 'task:AGT-002', + title: 'Quest awaiting revision', + status: 'READY', + hours: 1, + description: 'Quest awaiting revision', + taskKind: 'delivery', + campaignId: 'campaign:TRACE', + intentId: 'intent:TRACE', + }), + ], + campaigns: [campaign({ id: 'campaign:TRACE', title: 'Trace Campaign' })], + intents: [intent({ id: 'intent:TRACE', title: 'Trace Intent' })], + submissions: [ + submission({ + id: 'submission:AGT-CHANGES', + questId: 'task:AGT-002', + status: 'CHANGES_REQUESTED', + submittedBy: 'agent.other', + submittedAt: 100, + tipPatchsetId: 'patchset:AGT-CHANGES', + }), + ], + }); + + mocks.createGraphContext.mockReturnValue({ + fetchSnapshot: vi.fn().mockResolvedValue(snapshot), + fetchEntityDetail: vi.fn(), + filterSnapshot: vi.fn(), + invalidateCache: vi.fn(), + get graph() { + throw new Error('not used in test'); + }, + }); + + const service = new AgentBriefingService( + makeGraphWithHandoffs([]), + makeRoadmap([ + makeQuestEntity({ + id: 'task:AGT-002', + title: 'Quest awaiting revision', + description: 'Quest awaiting revision', + }), + ]), + 'agent.hal', + ); + + const briefing = await service.buildBriefing(); + + expect(briefing.reviewQueue).toEqual([]); + }); }); diff --git a/test/unit/AgentSubmissionService.test.ts b/test/unit/AgentSubmissionService.test.ts index 4ecd0c8..baaf320 100644 --- a/test/unit/AgentSubmissionService.test.ts +++ b/test/unit/AgentSubmissionService.test.ts @@ -4,6 +4,7 @@ import { makeSnapshot, campaign, decision, intent, quest, review, submission } f import { AGENT_SUBMISSION_STALE_HOURS, AgentSubmissionService, + determineSubmissionNextStep, } from '../../src/domain/services/AgentSubmissionService.js'; const mocks = vi.hoisted(() => ({ @@ -81,6 +82,14 @@ describe('AgentSubmissionService', () => { submittedAt: asOf - (60 * 60 * 1000), tipPatchsetId: 'patchset:REV-001', }), + submission({ + id: 'submission:REV-002', + questId: 'task:REV-001', + status: 'CHANGES_REQUESTED', + submittedBy: 'agent.other', + submittedAt: asOf - (45 * 60 * 1000), + tipPatchsetId: 'patchset:REV-002', + }), submission({ id: 'submission:TERM-001', questId: 'task:OWN-001', @@ -147,6 +156,7 @@ describe('AgentSubmissionService', () => { }, }, ]); + expect(result.reviewable.map((entry) => entry.submissionId)).not.toContain('submission:REV-002'); expect(result.attentionNeeded.map((entry) => entry.submissionId)).toEqual([ 'submission:OWN-002', 'submission:OWN-001', @@ -213,4 +223,25 @@ describe('AgentSubmissionService', () => { expect(result.owned).toHaveLength(1); expect(result.owned[0]?.submissionId).toBe('submission:OWN-001'); }); + + it('routes external CHANGES_REQUESTED submissions to inspection instead of review', () => { + const nextStep = determineSubmissionNextStep( + submission({ + id: 'submission:REV-CHANGES', + questId: 'task:REV-001', + status: 'CHANGES_REQUESTED', + submittedBy: 'agent.other', + submittedAt: Date.UTC(2026, 2, 12, 20, 0, 0), + tipPatchsetId: 'patchset:REV-CHANGES', + }), + 'agent.hal', + ); + + expect(nextStep).toEqual({ + kind: 'inspect', + targetId: 'task:REV-001', + reason: 'The current tip is blocked by requested changes; wait for the submitter to revise before reviewing again.', + supportedByActionKernel: false, + }); + }); }); From 6aeccea2813121a4727d9624f8c26f606861c17b Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 13 Mar 2026 12:12:26 -0700 Subject: [PATCH 15/22] Unify settlement review gates and agent next queues --- docs/canonical/AGENT_PROTOCOL.md | 11 +- src/cli/commands/submission.ts | 4 +- src/domain/services/AgentActionService.ts | 38 +---- src/domain/services/AgentBriefingService.ts | 155 +++++++++++++++++- src/domain/services/SettlementGateService.ts | 66 ++++++++ .../adapters/GitWorkspaceAdapter.ts | 4 +- src/ports/WorkspacePort.ts | 2 +- test/unit/AgentActionService.test.ts | 6 +- test/unit/AgentBriefingService.test.ts | 100 +++++++++++ test/unit/SettlementGateService.test.ts | 42 ++++- test/unit/SignedSettlementCommands.test.ts | 109 +++++++++++- 11 files changed, 481 insertions(+), 56 deletions(-) diff --git a/docs/canonical/AGENT_PROTOCOL.md b/docs/canonical/AGENT_PROTOCOL.md index 1d794ca..5868e73 100644 --- a/docs/canonical/AGENT_PROTOCOL.md +++ b/docs/canonical/AGENT_PROTOCOL.md @@ -126,6 +126,11 @@ Each candidate must include at least: The first candidate is the default recommendation. Remaining candidates are ordered alternatives. +`next` should combine quest-shaping work with active submission workflow +candidates such as `review`, `merge`, and `inspect`. When a candidate needs +additional operator input, it should still be surfaced with machine-readable +blocking reasons instead of silently disappearing from the queue. + ### 4.3 `submissions --json` `submissions` is the agent-facing queue view. It should group at least: @@ -197,9 +202,9 @@ Checkpoint-2 action kinds are: These are the routine agent actions that should be executable through `act` in the checkpoint-2 kernel. -`seal` is review-gated in the agent kernel. An agent may only seal a quest when -the latest linked submission is independently approved; `seal` must not bypass -the submission review loop. +`seal` is review-gated. A quest may only be sealed when the latest linked +submission is independently approved; neither `act seal` nor direct `seal` +may bypass the submission review loop. The current runtime now ships that routine action set: diff --git a/src/cli/commands/submission.ts b/src/cli/commands/submission.ts index cd33a81..c708648 100644 --- a/src/cli/commands/submission.ts +++ b/src/cli/commands/submission.ts @@ -41,7 +41,7 @@ export function registerSubmissionCommands(program: Command, ctx: CliContext): v let commitShas: string[] | undefined; try { headRef = await workspace.getHeadCommit(workspaceRef); - commitShas = await workspace.getCommitsSince(opts.base); + commitShas = await workspace.getCommitsSince(opts.base, workspaceRef); } catch { // Non-fatal: workspace info is optional } @@ -108,7 +108,7 @@ export function registerSubmissionCommands(program: Command, ctx: CliContext): v let commitShas: string[] | undefined; try { headRef = await workspace.getHeadCommit(workspaceRef); - commitShas = await workspace.getCommitsSince(opts.base); + commitShas = await workspace.getCommitsSince(opts.base, workspaceRef); } catch { // Non-fatal } diff --git a/src/domain/services/AgentActionService.ts b/src/domain/services/AgentActionService.ts index f1b8ee4..cfe0698 100644 --- a/src/domain/services/AgentActionService.ts +++ b/src/domain/services/AgentActionService.ts @@ -753,7 +753,7 @@ export class AgentActionValidator { let commitShas: string[] | undefined; try { headRef = await workspace.getHeadCommit(workspaceRef); - commitShas = await workspace.getCommitsSince(baseRef); + commitShas = await workspace.getCommitsSince(baseRef, workspaceRef); } catch { // Non-fatal: submission packets can omit workspace metadata beyond workspaceRef. } @@ -987,42 +987,6 @@ export class AgentActionValidator { }); } - const submission = detail?.questDetail?.submission; - if (!submission) { - return failAssessment(request, 'approved-submission-required', [ - `seal requires an independently approved submission for ${request.targetId}; no submission is linked to this quest.`, - ], { - normalizedArgs: { - artifactHash, - rationale, - }, - underlyingCommand: `xyph seal ${request.targetId}`, - sideEffects: [ - `create artifact:${request.targetId}`, - 'status -> DONE', - 'completed_at -> now', - ], - }); - } - if (submission.status !== 'APPROVED') { - return failAssessment(request, 'approved-submission-required', [ - `seal requires an independently approved submission for ${request.targetId}; latest submission ${submission.id} is ${submission.status}.`, - ], { - normalizedArgs: { - artifactHash, - rationale, - submissionId: submission.id, - submissionStatus: submission.status, - }, - underlyingCommand: `xyph seal ${request.targetId}`, - sideEffects: [ - `create artifact:${request.targetId}`, - 'status -> DONE', - 'completed_at -> now', - ], - }); - } - const keyring = new FsKeyringAdapter(); const sealService = new GuildSealService(keyring); if (!sealService.hasPrivateKey(this.agentId) && !allowUnsignedScrollsForSettlement()) { diff --git a/src/domain/services/AgentBriefingService.ts b/src/domain/services/AgentBriefingService.ts index 424c23a..80ed0c8 100644 --- a/src/domain/services/AgentBriefingService.ts +++ b/src/domain/services/AgentBriefingService.ts @@ -9,6 +9,7 @@ import { AgentActionValidator } from './AgentActionService.js'; import { determineSubmissionNextStep, isReviewableByAgent, + type AgentSubmissionEntry, type AgentSubmissionNextStep, } from './AgentSubmissionService.js'; import { @@ -83,7 +84,7 @@ export interface AgentBriefing { export interface AgentNextCandidate extends AgentActionCandidate { questTitle: string; questStatus: string; - source: 'assignment' | 'frontier' | 'planning'; + source: 'assignment' | 'frontier' | 'planning' | 'submission'; } function determineSource( @@ -98,17 +99,39 @@ function determineSource( function kindPriority(kind: string): number { switch (kind) { - case 'claim': + case 'merge': return 0; - case 'ready': + case 'review': return 1; - case 'packet': + case 'claim': return 2; + case 'ready': + return 3; + case 'packet': + return 4; + case 'revise': + return 5; + case 'inspect': + return 6; default: return 9; } } +function sourcePriority(source: AgentNextCandidate['source']): number { + switch (source) { + case 'assignment': + return 0; + case 'submission': + return 1; + case 'frontier': + return 2; + case 'planning': + default: + return 3; + } +} + export class AgentBriefingService { private readonly readiness: ReadinessService; private readonly recommender: AgentRecommender; @@ -182,10 +205,11 @@ export class AgentBriefingService { } } + candidates.push(...this.buildSubmissionCandidates(snapshot)); + candidates.sort((a, b) => + sourcePriority(a.source) - sourcePriority(b.source) || Number(b.allowed) - Number(a.allowed) || - (a.source === 'assignment' ? 0 : a.source === 'frontier' ? 1 : 2) - - (b.source === 'assignment' ? 0 : b.source === 'frontier' ? 1 : 2) || kindPriority(a.kind) - kindPriority(b.kind) || b.confidence - a.confidence || a.targetId.localeCompare(b.targetId) @@ -242,6 +266,125 @@ export class AgentBriefingService { return queue; } + private buildSubmissionCandidates(snapshot: GraphSnapshot): AgentNextCandidate[] { + const questById = new Map(snapshot.quests.map((quest) => [quest.id, quest] as const)); + const terminalStatuses = new Set(['MERGED', 'CLOSED']); + + const candidates = snapshot.submissions + .filter((submission) => !terminalStatuses.has(submission.status)) + .flatMap((submission) => { + const quest = questById.get(submission.questId); + const entry: AgentSubmissionEntry = { + submissionId: submission.id, + questId: submission.questId, + questTitle: quest?.title ?? submission.questId, + questStatus: quest?.status ?? null, + status: submission.status, + submittedBy: submission.submittedBy, + submittedAt: submission.submittedAt, + tipPatchsetId: submission.tipPatchsetId, + headsCount: submission.headsCount, + approvalCount: submission.approvalCount, + reviewCount: 0, + latestReviewAt: null, + latestReviewVerdict: null, + latestDecisionKind: null, + stale: false, + attentionCodes: [], + contextId: submission.questId, + nextStep: determineSubmissionNextStep(submission, this.agentId), + }; + + const candidate = this.toSubmissionCandidate(entry); + return candidate ? [candidate] : []; + }); + + return candidates; + } + + private toSubmissionCandidate(entry: AgentSubmissionEntry): AgentNextCandidate | null { + const base = { + questTitle: entry.questTitle, + questStatus: entry.questStatus ?? 'UNKNOWN', + source: 'submission' as const, + requiresHumanApproval: false, + }; + + switch (entry.nextStep.kind) { + case 'review': + return { + ...base, + kind: 'review', + targetId: entry.nextStep.targetId, + args: {}, + reason: entry.nextStep.reason, + confidence: 0.96, + dryRunSummary: 'Review the current tip patchset after providing a verdict and message.', + blockedBy: entry.nextStep.supportedByActionKernel + ? ['Provide verdict and message to execute the review.'] + : ['Review requires a resolved tip patchset before it can run through the action kernel.'], + allowed: false, + underlyingCommand: `xyph act review ${entry.nextStep.targetId}`, + sideEffects: [`create review on ${entry.nextStep.targetId}`], + validationCode: entry.nextStep.supportedByActionKernel + ? 'requires-additional-input' + : 'missing-tip-patchset', + }; + case 'merge': + return { + ...base, + kind: 'merge', + targetId: entry.submissionId, + args: { intoRef: 'main' }, + reason: entry.nextStep.reason, + confidence: 0.95, + dryRunSummary: 'Settle the independently approved submission after providing merge rationale.', + blockedBy: ['Provide rationale to execute the merge.'], + allowed: false, + underlyingCommand: `xyph act merge ${entry.submissionId}`, + sideEffects: [ + `merge submission ${entry.submissionId}`, + 'record merge decision', + 'auto-seal quest when eligible', + ], + validationCode: 'requires-additional-input', + }; + case 'revise': + return { + ...base, + kind: 'revise', + targetId: entry.submissionId, + args: {}, + reason: entry.nextStep.reason, + confidence: 0.91, + dryRunSummary: 'Prepare a new patchset revision after addressing requested changes.', + blockedBy: ['Revise is not yet exposed through act; inspect context and use xyph revise with a new description.'], + allowed: false, + underlyingCommand: `xyph revise ${entry.submissionId}`, + sideEffects: [`create new patchset for ${entry.submissionId}`], + validationCode: 'unsupported-by-action-kernel', + }; + case 'inspect': + return { + ...base, + kind: 'inspect', + targetId: entry.nextStep.targetId, + args: {}, + reason: entry.nextStep.reason, + confidence: 0.78, + dryRunSummary: 'Inspect quest and submission context before taking a follow-on action.', + blockedBy: [], + allowed: true, + underlyingCommand: `xyph context ${entry.nextStep.targetId}`, + sideEffects: [], + validationCode: null, + }; + case 'wait': + default: + return null; + } + } + private buildAlerts( assignments: AgentWorkSummary[], frontier: AgentWorkSummary[], diff --git a/src/domain/services/SettlementGateService.ts b/src/domain/services/SettlementGateService.ts index d51144a..b589186 100644 --- a/src/domain/services/SettlementGateService.ts +++ b/src/domain/services/SettlementGateService.ts @@ -1,10 +1,12 @@ import type { ComputedCompletionVerdict, QuestDetail } from '../models/dashboard.js'; +import type { PolicyNode, SubmissionNode } from '../models/dashboard.js'; export type SettlementAction = 'seal' | 'merge'; export type SettlementBlockCode = | 'quest-not-found' | 'missing-computed-completion' + | 'approved-submission-required' | 'governed-work-untracked' | 'governed-work-missing-evidence' | 'governed-work-linked-only' @@ -17,6 +19,8 @@ export interface SettlementGateAssessment { action: SettlementAction; policyId?: string; allowManualSeal?: boolean; + submissionId?: string; + submissionStatus?: string; tracked?: boolean; complete?: boolean; verdict?: ComputedCompletionVerdict; @@ -45,6 +49,39 @@ function blockCodeForVerdict( } } +function sealApprovalAssessment(args: { + questId: string; + action: SettlementAction; + appliedPolicy?: PolicyNode; + submission?: SubmissionNode; + computed?: QuestDetail['quest']['computedCompletion']; +}): SettlementGateAssessment | null { + const { questId, action, appliedPolicy, submission, computed } = args; + if (action !== 'seal') return null; + if (submission?.status === 'APPROVED') return null; + + return { + allowed: false, + questId, + governed: Boolean(appliedPolicy), + action, + policyId: appliedPolicy?.id, + allowManualSeal: appliedPolicy?.allowManualSeal, + submissionId: submission?.id, + submissionStatus: submission?.status, + tracked: computed?.tracked, + complete: computed?.complete, + verdict: computed?.verdict, + requirementCount: computed?.requirementCount, + criterionCount: computed?.criterionCount, + coverageRatio: computed?.coverageRatio, + code: 'approved-submission-required', + failingCriterionIds: computed?.failingCriterionIds ?? [], + linkedOnlyCriterionIds: computed?.linkedOnlyCriterionIds ?? [], + missingCriterionIds: computed?.missingCriterionIds ?? [], + }; +} + export function assessSettlementGate( detail: QuestDetail | null | undefined, action: SettlementAction, @@ -64,15 +101,26 @@ export function assessSettlementGate( const questId = detail.quest.id; const computed = detail.quest.computedCompletion; + const submission = detail.submission; const appliedPolicy = detail.policies.find((policy) => policy.id === computed?.policyId) ?? detail.policies[0]; + const approvalAssessment = sealApprovalAssessment({ + questId, + action, + appliedPolicy, + submission, + computed, + }); if (!appliedPolicy) { + if (approvalAssessment) return approvalAssessment; return { allowed: true, questId, governed: false, action, + submissionId: submission?.id, + submissionStatus: submission?.status, tracked: computed?.tracked, complete: computed?.complete, verdict: computed?.verdict, @@ -83,6 +131,7 @@ export function assessSettlementGate( } if (appliedPolicy.allowManualSeal) { + if (approvalAssessment) return approvalAssessment; return { allowed: true, questId, @@ -90,6 +139,8 @@ export function assessSettlementGate( action, policyId: appliedPolicy.id, allowManualSeal: true, + submissionId: submission?.id, + submissionStatus: submission?.status, tracked: computed?.tracked, complete: computed?.complete, verdict: computed?.verdict, @@ -110,6 +161,8 @@ export function assessSettlementGate( action, policyId: appliedPolicy.id, allowManualSeal: false, + submissionId: submission?.id, + submissionStatus: submission?.status, code: 'missing-computed-completion', failingCriterionIds: [], linkedOnlyCriterionIds: [], @@ -118,6 +171,7 @@ export function assessSettlementGate( } if (computed.complete) { + if (approvalAssessment) return approvalAssessment; return { allowed: true, questId, @@ -125,6 +179,8 @@ export function assessSettlementGate( action, policyId: appliedPolicy.id, allowManualSeal: false, + submissionId: submission?.id, + submissionStatus: submission?.status, tracked: computed.tracked, complete: computed.complete, verdict: computed.verdict, @@ -144,6 +200,8 @@ export function assessSettlementGate( action, policyId: appliedPolicy.id, allowManualSeal: false, + submissionId: submission?.id, + submissionStatus: submission?.status, tracked: computed.tracked, complete: computed.complete, verdict: computed.verdict, @@ -166,6 +224,12 @@ export function formatSettlementGateFailure( if (assessment.code === 'missing-computed-completion') { return `Cannot ${assessment.action} ${assessment.questId}: governed work is missing computed completion state for policy ${assessment.policyId}.`; } + if (assessment.code === 'approved-submission-required') { + if (assessment.submissionId && assessment.submissionStatus) { + return `Cannot ${assessment.action} ${assessment.questId}: latest submission ${assessment.submissionId} is ${assessment.submissionStatus}, so settlement still requires independent approval on the current tip.`; + } + return `Cannot ${assessment.action} ${assessment.questId}: settlement requires an independently approved submission on the current tip.`; + } const verdict = assessment.verdict ?? 'UNKNOWN'; const parts: string[] = [ @@ -192,6 +256,8 @@ export function settlementGateFailureData( governed: assessment.governed, policyId: assessment.policyId ?? null, allowManualSeal: assessment.allowManualSeal ?? null, + submissionId: assessment.submissionId ?? null, + submissionStatus: assessment.submissionStatus ?? null, code: assessment.code ?? null, tracked: assessment.tracked ?? null, complete: assessment.complete ?? null, diff --git a/src/infrastructure/adapters/GitWorkspaceAdapter.ts b/src/infrastructure/adapters/GitWorkspaceAdapter.ts index 8279024..fbba2a4 100644 --- a/src/infrastructure/adapters/GitWorkspaceAdapter.ts +++ b/src/infrastructure/adapters/GitWorkspaceAdapter.ts @@ -20,8 +20,8 @@ export class GitWorkspaceAdapter implements WorkspacePort { return this.git(['rev-parse', '--abbrev-ref', 'HEAD']); } - public async getCommitsSince(base: string): Promise { - const output = this.git(['log', `${base}..HEAD`, '--format=%H']); + public async getCommitsSince(base: string, ref = 'HEAD'): Promise { + const output = this.git(['log', `${base}..${ref}`, '--format=%H']); if (output === '') return []; return output.split('\n'); } diff --git a/src/ports/WorkspacePort.ts b/src/ports/WorkspacePort.ts index 398b21e..dbd3960 100644 --- a/src/ports/WorkspacePort.ts +++ b/src/ports/WorkspacePort.ts @@ -4,7 +4,7 @@ */ export interface WorkspacePort { getWorkspaceRef(): Promise; - getCommitsSince(base: string): Promise; + getCommitsSince(base: string, ref?: string): Promise; getHeadCommit(ref: string): Promise; isMerged(ref: string, into: string): Promise; merge(ref: string, into: string): Promise; diff --git a/test/unit/AgentActionService.test.ts b/test/unit/AgentActionService.test.ts index 2f786df..8cca3e2 100644 --- a/test/unit/AgentActionService.test.ts +++ b/test/unit/AgentActionService.test.ts @@ -125,8 +125,8 @@ vi.mock('../../src/infrastructure/adapters/GitWorkspaceAdapter.js', () => ({ return mocks.getHeadCommit(ref); } - getCommitsSince(base: string) { - return mocks.getCommitsSince(base); + getCommitsSince(base: string, ref?: string) { + return mocks.getCommitsSince(base, ref); } isMerged(ref: string, into: string) { @@ -811,7 +811,7 @@ describe('AgentActionService', () => { expect(mocks.validateSubmit).toHaveBeenCalledWith('task:AGT-001', 'agent.hal'); expect(mocks.getWorkspaceRef).toHaveBeenCalledTimes(1); expect(mocks.getHeadCommit).toHaveBeenCalledWith('feat/agent-action-kernel-v1'); - expect(mocks.getCommitsSince).toHaveBeenCalledWith('main'); + expect(mocks.getCommitsSince).toHaveBeenCalledWith('main', 'feat/agent-action-kernel-v1'); expect(outcome).toMatchObject({ kind: 'submit', targetId: 'task:AGT-001', diff --git a/test/unit/AgentBriefingService.test.ts b/test/unit/AgentBriefingService.test.ts index a1c5764..c4224dc 100644 --- a/test/unit/AgentBriefingService.test.ts +++ b/test/unit/AgentBriefingService.test.ts @@ -319,6 +319,106 @@ describe('AgentBriefingService', () => { }); }); + it('includes review and merge candidates from active submission queues', async () => { + const snapshot = makeSnapshot({ + quests: [ + quest({ + id: 'task:AGT-REVIEW', + title: 'Quest awaiting review', + status: 'IN_PROGRESS', + hours: 2, + description: 'Quest awaiting review', + taskKind: 'delivery', + assignedTo: 'agent.other', + campaignId: 'campaign:TRACE', + intentId: 'intent:TRACE', + }), + quest({ + id: 'task:AGT-MERGE', + title: 'Quest awaiting merge', + status: 'IN_PROGRESS', + hours: 1, + description: 'Quest awaiting merge', + taskKind: 'delivery', + assignedTo: 'agent.hal', + campaignId: 'campaign:TRACE', + intentId: 'intent:TRACE', + }), + ], + campaigns: [campaign({ id: 'campaign:TRACE', title: 'Trace Campaign' })], + intents: [intent({ id: 'intent:TRACE', title: 'Trace Intent' })], + submissions: [ + submission({ + id: 'submission:AGT-REVIEW', + questId: 'task:AGT-REVIEW', + status: 'OPEN', + submittedBy: 'agent.other', + submittedAt: 100, + tipPatchsetId: 'patchset:AGT-REVIEW', + }), + submission({ + id: 'submission:AGT-MERGE', + questId: 'task:AGT-MERGE', + status: 'APPROVED', + submittedBy: 'agent.hal', + submittedAt: 200, + tipPatchsetId: 'patchset:AGT-MERGE', + }), + ], + sortedTaskIds: ['task:AGT-REVIEW', 'task:AGT-MERGE'], + }); + + mocks.createGraphContext.mockReturnValue({ + fetchSnapshot: vi.fn().mockResolvedValue(snapshot), + fetchEntityDetail: vi.fn(), + filterSnapshot: vi.fn(), + invalidateCache: vi.fn(), + get graph() { + throw new Error('not used in test'); + }, + }); + + const service = new AgentBriefingService( + makeGraphWithHandoffs([]), + makeRoadmap([ + makeQuestEntity({ + id: 'task:AGT-REVIEW', + title: 'Quest awaiting review', + status: 'IN_PROGRESS', + description: 'Quest awaiting review', + assignedTo: 'agent.other', + }), + makeQuestEntity({ + id: 'task:AGT-MERGE', + title: 'Quest awaiting merge', + status: 'IN_PROGRESS', + description: 'Quest awaiting merge', + assignedTo: 'agent.hal', + }), + ]), + 'agent.hal', + ); + + const candidates = await service.next(5); + + expect(candidates).toHaveLength(2); + expect(candidates[0]).toMatchObject({ + kind: 'merge', + targetId: 'submission:AGT-MERGE', + source: 'submission', + allowed: false, + validationCode: 'requires-additional-input', + args: { intoRef: 'main' }, + }); + expect(candidates[1]).toMatchObject({ + kind: 'review', + targetId: 'patchset:AGT-REVIEW', + source: 'submission', + allowed: false, + validationCode: 'requires-additional-input', + }); + }); + it('omits CHANGES_REQUESTED submissions from the briefing review queue', async () => { const snapshot = makeSnapshot({ quests: [ diff --git a/test/unit/SettlementGateService.test.ts b/test/unit/SettlementGateService.test.ts index 56df696..abc9106 100644 --- a/test/unit/SettlementGateService.test.ts +++ b/test/unit/SettlementGateService.test.ts @@ -29,6 +29,16 @@ function makeQuestDetail(overrides?: Partial): QuestDetail { policyId: 'policy:TRACE', }, }, + submission: { + id: 'submission:Q1', + questId: 'task:Q1', + status: 'APPROVED', + tipPatchsetId: 'patchset:Q1', + headsCount: 1, + approvalCount: 1, + submittedBy: 'agent.other', + submittedAt: Date.UTC(2026, 2, 12, 12, 0, 0), + }, reviews: [], decisions: [], stories: [], @@ -54,12 +64,42 @@ describe('SettlementGateService', () => { it('allows ungoverned work to settle', () => { const assessment = assessSettlementGate(makeQuestDetail({ policies: [], - }), 'seal'); + }), 'merge'); expect(assessment.allowed).toBe(true); expect(assessment.governed).toBe(false); }); + it('blocks seal when the latest submission is not independently approved', () => { + const assessment = assessSettlementGate(makeQuestDetail({ + submission: { + id: 'submission:Q1', + questId: 'task:Q1', + status: 'OPEN', + tipPatchsetId: 'patchset:Q1', + headsCount: 1, + approvalCount: 0, + submittedBy: 'agent.hal', + submittedAt: Date.UTC(2026, 2, 12, 12, 0, 0), + }, + }), 'seal'); + + expect(assessment).toMatchObject({ + allowed: false, + action: 'seal', + code: 'approved-submission-required', + submissionId: 'submission:Q1', + submissionStatus: 'OPEN', + }); + expect(formatSettlementGateFailure(assessment)).toContain('latest submission submission:Q1 is OPEN'); + expect(settlementGateFailureData(assessment)).toMatchObject({ + action: 'seal', + code: 'approved-submission-required', + submissionId: 'submission:Q1', + submissionStatus: 'OPEN', + }); + }); + it('blocks governed work when computed completion is incomplete', () => { const assessment = assessSettlementGate(makeQuestDetail({ quest: { diff --git a/test/unit/SignedSettlementCommands.test.ts b/test/unit/SignedSettlementCommands.test.ts index b63838c..3031841 100644 --- a/test/unit/SignedSettlementCommands.test.ts +++ b/test/unit/SignedSettlementCommands.test.ts @@ -14,11 +14,15 @@ const mocks = vi.hoisted(() => ({ sign: vi.fn(), payloadDigest: vi.fn(), getOpenSubmissionsForQuest: vi.fn(), + validateSubmit: vi.fn(), validateMerge: vi.fn(), + submit: vi.fn(), getPatchsetWorkspaceRef: vi.fn(), getSubmissionQuestId: vi.fn(), getQuestStatus: vi.fn(), decide: vi.fn(), + getWorkspaceRef: vi.fn(), + getCommitsSince: vi.fn(), isMerged: vi.fn(), merge: vi.fn(), getHeadCommit: vi.fn(), @@ -53,6 +57,10 @@ vi.mock('../../src/infrastructure/adapters/WarpSubmissionAdapter.js', () => ({ return mocks.getOpenSubmissionsForQuest(id); } + submit(input: unknown): Promise<{ patchSha: string }> { + return mocks.submit(input); + } + getPatchsetWorkspaceRef(id: string): Promise { return mocks.getPatchsetWorkspaceRef(id); } @@ -73,6 +81,10 @@ vi.mock('../../src/infrastructure/adapters/WarpSubmissionAdapter.js', () => ({ vi.mock('../../src/domain/services/SubmissionService.js', () => ({ SubmissionService: class SubmissionService { + validateSubmit(questId: string, agentId: string): Promise { + return mocks.validateSubmit(questId, agentId); + } + validateMerge(submissionId: string, agentId: string, patchset?: string): Promise<{ tipPatchsetId: string }> { return mocks.validateMerge(submissionId, agentId, patchset); } @@ -81,6 +93,14 @@ vi.mock('../../src/domain/services/SubmissionService.js', () => ({ vi.mock('../../src/infrastructure/adapters/GitWorkspaceAdapter.js', () => ({ GitWorkspaceAdapter: class GitWorkspaceAdapter { + getWorkspaceRef(): Promise { + return mocks.getWorkspaceRef(); + } + + getCommitsSince(base: string, ref?: string): Promise { + return mocks.getCommitsSince(base, ref); + } + isMerged(ref: string, into: string): Promise { return mocks.isMerged(ref, into); } @@ -134,6 +154,16 @@ function makeQuestDetail( policyId: 'policy:TRACE', }, }, + submission: { + id: 'submission:Q1', + questId: 'task:Q1', + status: 'APPROVED', + tipPatchsetId: 'patchset:Q1', + headsCount: 1, + approvalCount: 1, + submittedBy: 'agent.other', + submittedAt: Date.UTC(2026, 2, 12, 12, 0, 0), + }, reviews: [], decisions: [], stories: [], @@ -218,7 +248,11 @@ describe('signed settlement enforcement', () => { mocks.sign.mockResolvedValue({ keyId: 'did:key:test', alg: 'ed25519' }); mocks.payloadDigest.mockReturnValue('blake3:test'); mocks.getOpenSubmissionsForQuest.mockResolvedValue([]); + mocks.validateSubmit.mockResolvedValue(undefined); mocks.validateMerge.mockResolvedValue({ tipPatchsetId: 'patchset:tip' }); + mocks.submit.mockResolvedValue({ patchSha: 'patch:submit' }); + mocks.getWorkspaceRef.mockResolvedValue('feat/current'); + mocks.getCommitsSince.mockResolvedValue(['abc123def4567890']); mocks.getPatchsetWorkspaceRef.mockResolvedValue('feature/quest'); mocks.getSubmissionQuestId.mockResolvedValue('task:Q1'); mocks.getQuestStatus.mockResolvedValue('PLANNED'); @@ -370,6 +404,79 @@ describe('signed settlement enforcement', () => { }); }); + it('seal fails when the latest submission is not independently approved', async () => { + mocks.fetchEntityDetail.mockResolvedValue(makeQuestDetail({ + submission: { + id: 'submission:Q1', + questId: 'task:Q1', + status: 'OPEN', + tipPatchsetId: 'patchset:Q1', + headsCount: 1, + approvalCount: 0, + submittedBy: 'agent.test', + submittedAt: Date.UTC(2026, 2, 12, 12, 0, 0), + }, + })); + + const program = new Command(); + registerArtifactCommands(program, createJsonCtx()); + + await program.parseAsync( + ['seal', 'task:Q1', '--artifact', 'artifact-sha', '--rationale', 'attempt unreviewed seal'], + { from: 'user' }, + ); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(mocks.sign).not.toHaveBeenCalled(); + + const output = JSON.parse(String(logSpy.mock.calls.at(-1)?.[0])); + expect(output).toMatchObject({ + success: false, + error: expect.stringContaining('latest submission submission:Q1 is OPEN'), + data: { + action: 'seal', + questId: 'task:Q1', + submissionId: 'submission:Q1', + submissionStatus: 'OPEN', + code: 'approved-submission-required', + }, + }); + }); + + it('submit derives commit metadata from the nominated workspace ref', async () => { + mocks.getHeadCommit.mockResolvedValue('feedfacecafebeef'); + + const program = new Command(); + registerSubmissionCommands(program, createJsonCtx()); + + await program.parseAsync( + [ + 'submit', + 'task:Q1', + '--description', + 'Submit this quest with the nominated workspace branch.', + '--base', + 'main', + '--workspace', + 'feature/review-me', + ], + { from: 'user' }, + ); + + expect(mocks.validateSubmit).toHaveBeenCalledWith('task:Q1', 'agent.test'); + expect(mocks.getHeadCommit).toHaveBeenCalledWith('feature/review-me'); + expect(mocks.getCommitsSince).toHaveBeenCalledWith('main', 'feature/review-me'); + expect(mocks.submit).toHaveBeenCalledWith(expect.objectContaining({ + questId: 'task:Q1', + patchset: expect.objectContaining({ + workspaceRef: 'feature/review-me', + baseRef: 'main', + headRef: 'feedfacecafebeef', + commitShas: ['abc123def4567890'], + }), + })); + }); + it('merge fails before git settlement when auto-seal needs a key', async () => { mocks.hasPrivateKey.mockReturnValue(false); @@ -439,7 +546,7 @@ describe('signed settlement enforcement', () => { success: false, error: expect.stringContaining('policy policy:TRACE blocks settlement'), data: { - submissionId: 'submission:S1', + submissionId: 'submission:Q1', action: 'merge', questId: 'task:Q1', governed: true, From 40cf9968a12fa00a89c12643a5dc7b1aa05fff52 Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 13 Mar 2026 12:51:10 -0700 Subject: [PATCH 16/22] Fix: fail act merge when decision write does not land --- src/cli/commands/agent.ts | 20 ++++++++-- src/domain/services/AgentActionService.ts | 4 +- test/unit/AgentActionService.test.ts | 4 +- test/unit/AgentCommands.test.ts | 47 +++++++++++++++++++++++ 4 files changed, 68 insertions(+), 7 deletions(-) diff --git a/src/cli/commands/agent.ts b/src/cli/commands/agent.ts index d6a6876..cc4a31a 100644 --- a/src/cli/commands/agent.ts +++ b/src/cli/commands/agent.ts @@ -94,7 +94,11 @@ function renderHumanOutcome( ctx: CliContext, outcome: AgentActionOutcome, ): void { - const label = outcome.result === 'dry-run' ? '[DRY RUN]' : '[OK]'; + const label = outcome.result === 'dry-run' + ? '[DRY RUN]' + : outcome.result === 'partial-failure' + ? '[PARTIAL FAILURE]' + : '[OK]'; ctx.ok(`${label} ${outcome.kind} ${outcome.targetId}`); ctx.muted(` Command: ${outcome.underlyingCommand}`); for (const effect of outcome.sideEffects) { @@ -547,11 +551,21 @@ export function registerAgentCommands(program: Command, ctx: CliContext): void { args: buildActionArgs(opts), }); - if (outcome.result === 'rejected') { - const reason = outcome.validation.reasons[0] ?? `Action '${actionKind}' was rejected`; + if (outcome.result === 'rejected' || outcome.result === 'partial-failure') { + const reason = outcome.result === 'partial-failure' + ? String( + (outcome.details?.['partialFailure'] as { message?: unknown } | undefined)?.message + ?? outcome.validation.reasons[0] + ?? `Action '${actionKind}' completed with a partial failure`, + ) + : outcome.validation.reasons[0] ?? `Action '${actionKind}' was rejected`; if (ctx.json) { return ctx.failWithData(reason, { ...outcome }); } + if (outcome.result === 'partial-failure') { + renderHumanOutcome(ctx, outcome); + return ctx.fail(`[PARTIAL FAILURE] ${reason}`); + } return ctx.fail(`[REJECTED] ${reason}`); } diff --git a/src/domain/services/AgentActionService.ts b/src/domain/services/AgentActionService.ts index cfe0698..fa12553 100644 --- a/src/domain/services/AgentActionService.ts +++ b/src/domain/services/AgentActionService.ts @@ -67,7 +67,7 @@ export interface AgentActionAssessment { } export interface AgentActionOutcome extends AgentActionAssessment { - result: 'dry-run' | 'success' | 'rejected'; + result: 'dry-run' | 'success' | 'partial-failure' | 'rejected'; patch: string | null; details: Record | null; } @@ -1678,7 +1678,7 @@ export class AgentActionService { const msg = err instanceof Error ? err.message : String(err); return { ...assessment, - result: 'success', + result: 'partial-failure', patch: null, details: { submissionId: action.targetId, diff --git a/test/unit/AgentActionService.test.ts b/test/unit/AgentActionService.test.ts index 8cca3e2..9834138 100644 --- a/test/unit/AgentActionService.test.ts +++ b/test/unit/AgentActionService.test.ts @@ -711,7 +711,7 @@ describe('AgentActionService', () => { expect(typeof outcome.details?.['decisionId']).toBe('string'); }); - it('reports committed merge state instead of rejection when recording the decision fails', async () => { + it('reports merge-decision write failure as a partial failure with reconciliation details', async () => { mocks.decide.mockRejectedValue(new Error('graph write failed')); const service = new AgentActionService( @@ -734,7 +734,7 @@ describe('AgentActionService', () => { kind: 'merge', targetId: 'submission:AGT-001', allowed: true, - result: 'success', + result: 'partial-failure', patch: null, details: { submissionId: 'submission:AGT-001', diff --git a/test/unit/AgentCommands.test.ts b/test/unit/AgentCommands.test.ts index 0b3cca5..072f011 100644 --- a/test/unit/AgentCommands.test.ts +++ b/test/unit/AgentCommands.test.ts @@ -986,4 +986,51 @@ describe('agent act command', () => { ); expect(ctx.jsonOut).not.toHaveBeenCalled(); }); + + it('routes partial-failure act results through the JSON error envelope', async () => { + const partialFailure = { + kind: 'merge', + targetId: 'submission:AGT-001', + allowed: true, + dryRun: false, + requiresHumanApproval: false, + validation: { + valid: true, + code: null, + reasons: [], + }, + normalizedArgs: { + intoRef: 'main', + rationale: 'Merge approved submission.', + }, + underlyingCommand: 'xyph merge submission:AGT-001', + sideEffects: ['merge feat/agent-action-kernel-v1 into main', 'create merge decision'], + result: 'partial-failure', + patch: null, + details: { + submissionId: 'submission:AGT-001', + mergeCommit: 'mergecommit123456', + partialFailure: { + stage: 'record-decision', + message: 'graph write failed', + }, + }, + }; + mocks.execute.mockResolvedValue(partialFailure); + + const ctx = makeCtx(); + const program = new Command(); + registerAgentCommands(program, ctx); + + await program.parseAsync( + ['act', 'merge', 'submission:AGT-001', '--rationale', 'Merge approved submission.'], + { from: 'user' }, + ); + + expect(ctx.failWithData).toHaveBeenCalledWith( + 'graph write failed', + partialFailure, + ); + expect(ctx.jsonOut).not.toHaveBeenCalled(); + }); }); From 253702ac15f670ffb10853d8daca7982af2405b8 Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 13 Mar 2026 12:51:42 -0700 Subject: [PATCH 17/22] docs: note merge partial-failure handling in alpha.15 --- CHANGELOG.md | 1 + docs/canonical/AGENT_PROTOCOL.md | 6 ++++-- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1253924..0c96e19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ All notable changes to XYPH will be documented in this file. ### Fixed +- **`act merge` now fails loudly when decision recording does not land** — if the git merge succeeds but XYPH cannot write the authoritative merge decision back into the graph, the agent action kernel now returns a non-success partial-failure outcome and the CLI exits with an error envelope instead of reporting the merge as fully successful. This keeps automation retryable and prevents silently “settled” submissions that never recorded their decision state - **Trust test fixture is now tracked correctly** — `.gitignore` now ignores only the repo-root `trust/` and `.xyph/` directories, so `test/fixtures/trust/keyring.json` is committed and available in CI. Fixes patch-ops matrix failures caused by missing public-key fixture data on clean checkouts - **Blocker counts exclude GRAVEYARD tasks** — `transitiveDownstream` BFS now excludes GRAVEYARD tasks from counts (matching DONE exclusion), and `filterSnapshot()` strips GRAVEYARD keys from the map. Fixes inconsistency where `directCount` (from filtered edges) and `transitiveCount` (from unfiltered BFS) could diverge when `--include-graveyard` was not set - **Landing screen auto-dismiss** — `showLanding` is now set to `false` when `snapshot-loaded` fires, so the dashboard appears immediately after data loads. Previously required a throwaway keypress to dismiss the landing screen, making navigation appear broken diff --git a/docs/canonical/AGENT_PROTOCOL.md b/docs/canonical/AGENT_PROTOCOL.md index 5868e73..5841544 100644 --- a/docs/canonical/AGENT_PROTOCOL.md +++ b/docs/canonical/AGENT_PROTOCOL.md @@ -167,8 +167,10 @@ The `--json` result must include: `validation` must contain machine-readable failure reasons when the action is rejected. Rejections must happen **before** any graph or workspace mutation. If a follow-on step fails after mutation has already been committed, the -outcome must stay truthful by returning success plus `warnings` and structured -`partialFailure` data instead of pretending nothing happened. +outcome must stay truthful. Non-critical follow-on failures may return success +plus `warnings` and structured `partialFailure` data, but failures to record +the authoritative graph-side settlement state must return a non-success outcome +with the committed side effects included so automation can reconcile and retry. ### 4.5 `handoff --json` diff --git a/package-lock.json b/package-lock.json index ae30a22..fb2a6e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "xyph", - "version": "1.0.0-alpha.14", + "version": "1.0.0-alpha.15", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "xyph", - "version": "1.0.0-alpha.14", + "version": "1.0.0-alpha.15", "hasInstallScript": true, "dependencies": { "@anthropic-ai/sdk": "^0.78.0", diff --git a/package.json b/package.json index 01983b0..9edfa79 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "xyph", - "version": "1.0.0-alpha.14", + "version": "1.0.0-alpha.15", "description": "Agent Planning and Orchestration framework powered by Causal Agents and WARP graphs.", "type": "module", "bin": { From 55c5eb47933af58f4dca9e59eacc32c10d4544e1 Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 13 Mar 2026 15:02:05 -0700 Subject: [PATCH 18/22] Fix claim ownership and merge head integrity --- src/cli/commands/coordination.ts | 6 + src/cli/commands/submission.ts | 19 +-- src/domain/services/AgentActionService.ts | 59 +++++++--- src/domain/services/AgentBriefingService.ts | 1 + src/domain/services/AgentContextService.ts | 97 +++++++++++++++- src/domain/services/AgentRecommender.ts | 14 ++- .../adapters/WarpSubmissionAdapter.ts | 24 ++++ test/unit/AgentActionService.test.ts | 38 +++++- test/unit/AgentContextService.test.ts | 109 +++++++++++++++++- test/unit/SignedSettlementCommands.test.ts | 19 +++ 10 files changed, 356 insertions(+), 30 deletions(-) diff --git a/src/cli/commands/coordination.ts b/src/cli/commands/coordination.ts index b43ebbf..2d4e999 100644 --- a/src/cli/commands/coordination.ts +++ b/src/cli/commands/coordination.ts @@ -19,6 +19,12 @@ export function registerCoordinationCommands(program: Command, ctx: CliContext): if (status !== 'READY') { throw new Error(`[INVALID_FROM] claim requires status READY, quest ${id} is ${status || 'unknown'}`); } + const assignedTo = typeof before['assigned_to'] === 'string' + ? before['assigned_to'] + : undefined; + if (assignedTo && assignedTo !== ctx.agentId) { + throw new Error(`[CONFLICT] claim requires an unassigned quest or an existing self-assignment, quest ${id} is assigned to ${assignedTo}`); + } ctx.warn(`[*] Attempting to claim ${id} as ${ctx.agentId}...`); diff --git a/src/cli/commands/submission.ts b/src/cli/commands/submission.ts index c708648..48ada5a 100644 --- a/src/cli/commands/submission.ts +++ b/src/cli/commands/submission.ts @@ -229,25 +229,26 @@ export function registerSubmissionCommands(program: Command, ctx: CliContext): v ); } - // Get workspace ref from the tip patchset + // Get immutable patchset merge ref plus workspace metadata for operator messages const workspaceRef = await adapter.getPatchsetWorkspaceRef(tipPatchsetId); if (typeof workspaceRef !== 'string') { return ctx.fail(`Could not resolve workspace ref from patchset ${tipPatchsetId}`); } + const mergeRef = await adapter.getPatchsetMergeRef(tipPatchsetId); + if (typeof mergeRef !== 'string') { + return ctx.fail(`Patchset ${tipPatchsetId} is missing immutable head metadata (head_ref or commit_shas)`); + } // Git settlement const workspace = new GitWorkspaceAdapter(process.cwd()); let mergeCommit: string | undefined; - const alreadyMerged = await workspace.isMerged(workspaceRef, opts.into); + const alreadyMerged = await workspace.isMerged(mergeRef, opts.into); if (alreadyMerged) { - mergeCommit = await workspace.getHeadCommit(opts.into); - if (!mergeCommit) { - return ctx.fail(`Could not resolve HEAD of ${opts.into}`); - } - ctx.muted(` Branch ${workspaceRef} already merged into ${opts.into}`); + mergeCommit = mergeRef; + ctx.muted(` Patchset tip ${mergeRef.slice(0, 7)} is already merged into ${opts.into}`); } else { - mergeCommit = await workspace.merge(workspaceRef, opts.into); - ctx.muted(` Merged ${workspaceRef} into ${opts.into}: ${mergeCommit.slice(0, 7)}`); + mergeCommit = await workspace.merge(mergeRef, opts.into); + ctx.muted(` Merged ${workspaceRef} @ ${mergeRef.slice(0, 7)} into ${opts.into}: ${mergeCommit.slice(0, 7)}`); } // Create merge decision diff --git a/src/domain/services/AgentActionService.ts b/src/domain/services/AgentActionService.ts index fa12553..1ec0a23 100644 --- a/src/domain/services/AgentActionService.ts +++ b/src/domain/services/AgentActionService.ts @@ -162,6 +162,8 @@ interface MergeAction { rationale: string; intoRef: string; tipPatchsetId: string; + mergeRef: string; + workspaceRef?: string; explicitPatchsetId?: string; questId?: string; shouldAutoSeal: boolean; @@ -340,6 +342,11 @@ export class AgentActionValidator { `claim requires status READY, quest ${request.targetId} is ${quest.status}`, ]); } + if (quest.assignedTo && quest.assignedTo !== this.agentId) { + return failAssessment(request, 'already-assigned', [ + `claim requires an unassigned quest or an existing self-assignment, quest ${request.targetId} is assigned to ${quest.assignedTo}`, + ]); + } return successAssessment( request, @@ -1143,6 +1150,27 @@ export class AgentActionValidator { ], }); } + const mergeRef = await adapter.getPatchsetMergeRef(tipPatchsetId); + if (typeof mergeRef !== 'string') { + return failAssessment(request, 'missing-patchset-head', [ + `Patchset ${tipPatchsetId} is missing immutable head metadata (head_ref or commit_shas); resubmit or revise before merging.`, + ], { + normalizedArgs: { + rationale, + intoRef, + patchsetId: explicitPatchsetId ?? null, + tipPatchsetId, + workspaceRef, + questId: questId ?? null, + }, + underlyingCommand: `xyph merge ${request.targetId}`, + sideEffects: [ + `merge ${workspaceRef} into ${intoRef}`, + 'create merge decision', + ...(shouldAutoSeal ? ['auto-seal quest'] : []), + ], + }); + } return successAssessment( request, @@ -1152,6 +1180,8 @@ export class AgentActionValidator { rationale, intoRef, tipPatchsetId, + mergeRef, + workspaceRef, explicitPatchsetId, questId, shouldAutoSeal, @@ -1161,13 +1191,14 @@ export class AgentActionValidator { intoRef, patchsetId: explicitPatchsetId ?? null, tipPatchsetId, + mergeRef, questId: questId ?? null, shouldAutoSeal, workspaceRef, }, `xyph merge ${request.targetId}`, [ - `merge ${workspaceRef} into ${intoRef}`, + `merge ${mergeRef} into ${intoRef}`, 'create merge decision', ...(shouldAutoSeal ? ['auto-seal quest'] : []), ], @@ -1267,14 +1298,19 @@ export class AgentActionService { action: ClaimAction, ): Promise { const graph = await this.graphPort.getGraph(); + const now = Date.now(); const sha = await graph.patch((p) => { p.setProperty(action.targetId, 'assigned_to', this.agentId) .setProperty(action.targetId, 'status', 'IN_PROGRESS') - .setProperty(action.targetId, 'claimed_at', Date.now()); + .setProperty(action.targetId, 'claimed_at', now); }); const props = await graph.getNodeProps(action.targetId); - const confirmed = !!(props && props['assigned_to'] === this.agentId); + const confirmed = !!( + props && + props['assigned_to'] === this.agentId && + props['claimed_at'] === now + ); if (!confirmed) { const winner = props ? String(props['assigned_to']) : 'unknown'; return { @@ -1301,6 +1337,7 @@ export class AgentActionService { id: action.targetId, assignedTo: this.agentId, status: 'IN_PROGRESS', + claimedAt: now, }, }; } @@ -1645,24 +1682,16 @@ export class AgentActionService { assessment: ValidatedAssessment, action: MergeAction, ): Promise { - const adapter = new WarpSubmissionAdapter(this.graphPort, this.agentId); - const workspaceRef = await adapter.getPatchsetWorkspaceRef(action.tipPatchsetId); - if (typeof workspaceRef !== 'string') { - throw new Error(`Could not resolve workspace ref from patchset ${action.tipPatchsetId}`); - } - const workspace = new GitWorkspaceAdapter(process.cwd()); let mergeCommit: string | undefined; - const alreadyMerged = await workspace.isMerged(workspaceRef, action.intoRef); + const alreadyMerged = await workspace.isMerged(action.mergeRef, action.intoRef); if (alreadyMerged) { - mergeCommit = await workspace.getHeadCommit(action.intoRef); - if (!mergeCommit) { - throw new Error(`Could not resolve HEAD of ${action.intoRef}`); - } + mergeCommit = action.mergeRef; } else { - mergeCommit = await workspace.merge(workspaceRef, action.intoRef); + mergeCommit = await workspace.merge(action.mergeRef, action.intoRef); } + const adapter = new WarpSubmissionAdapter(this.graphPort, this.agentId); const decisionId = autoId('decision:'); let patchSha: string | null = null; try { diff --git a/src/domain/services/AgentBriefingService.ts b/src/domain/services/AgentBriefingService.ts index 80ed0c8..6f869cf 100644 --- a/src/domain/services/AgentBriefingService.ts +++ b/src/domain/services/AgentBriefingService.ts @@ -144,6 +144,7 @@ export class AgentBriefingService { this.readiness = new ReadinessService(roadmap); this.recommender = new AgentRecommender( new AgentActionValidator(graphPort, roadmap, agentId), + agentId, ); } diff --git a/src/domain/services/AgentContextService.ts b/src/domain/services/AgentContextService.ts index f502e33..8ea37f0 100644 --- a/src/domain/services/AgentContextService.ts +++ b/src/domain/services/AgentContextService.ts @@ -6,6 +6,7 @@ import { createGraphContext } from '../../infrastructure/GraphContext.js'; import { computeFrontier } from './DepAnalysis.js'; import { ReadinessService, type ReadinessAssessment } from './ReadinessService.js'; import { AgentActionValidator } from './AgentActionService.js'; +import { determineSubmissionNextStep } from './AgentSubmissionService.js'; import { AgentRecommender, type AgentActionCandidate, @@ -77,15 +78,18 @@ export function buildAgentDependencyContext( export class AgentContextService { private readonly readiness: ReadinessService; private readonly recommender: AgentRecommender; + private readonly agentId: string; constructor( private readonly graphPort: GraphPort, roadmap: RoadmapQueryPort, agentId: string, ) { + this.agentId = agentId; this.readiness = new ReadinessService(roadmap); this.recommender = new AgentRecommender( new AgentActionValidator(graphPort, roadmap, agentId), + agentId, ); } @@ -109,11 +113,21 @@ export class AgentContextService { const quest = detail.questDetail.quest; const readiness = await this.readiness.assess(id, { transition: false }); const dependency = buildAgentDependencyContext(snapshot, quest); - const recommendedActions = await this.recommender.recommendForQuest( + const questActions = await this.recommender.recommendForQuest( quest, readiness, dependency, ); + const submissionAction = detail.questDetail.submission + ? this.toSubmissionCandidate(detail.questDetail.submission) + : null; + const recommendedActions = submissionAction + ? [...questActions, submissionAction].sort((a, b) => + Number(b.allowed) - Number(a.allowed) || + b.confidence - a.confidence || + a.kind.localeCompare(b.kind) + ) + : questActions; return { detail, @@ -122,4 +136,85 @@ export class AgentContextService { recommendedActions, }; } + + private toSubmissionCandidate( + submission: NonNullable['submission'], + ): AgentActionCandidate | null { + if (!submission) return null; + + const nextStep = determineSubmissionNextStep(submission, this.agentId); + switch (nextStep.kind) { + case 'review': + return { + kind: 'review', + targetId: nextStep.targetId, + args: {}, + reason: nextStep.reason, + confidence: 0.96, + requiresHumanApproval: false, + dryRunSummary: 'Review the current tip patchset after providing a verdict and message.', + blockedBy: nextStep.supportedByActionKernel + ? ['Provide verdict and message to execute the review.'] + : ['Review requires a resolved tip patchset before it can run through the action kernel.'], + allowed: false, + underlyingCommand: `xyph act review ${nextStep.targetId}`, + sideEffects: [`create review on ${nextStep.targetId}`], + validationCode: nextStep.supportedByActionKernel + ? 'requires-additional-input' + : 'missing-tip-patchset', + }; + case 'merge': + return { + kind: 'merge', + targetId: submission.id, + args: { intoRef: 'main' }, + reason: nextStep.reason, + confidence: 0.95, + requiresHumanApproval: false, + dryRunSummary: 'Settle the independently approved submission after providing merge rationale.', + blockedBy: ['Provide rationale to execute the merge.'], + allowed: false, + underlyingCommand: `xyph act merge ${submission.id}`, + sideEffects: [ + `merge submission ${submission.id}`, + 'record merge decision', + 'auto-seal quest when eligible', + ], + validationCode: 'requires-additional-input', + }; + case 'revise': + return { + kind: 'revise', + targetId: submission.id, + args: {}, + reason: nextStep.reason, + confidence: 0.91, + requiresHumanApproval: false, + dryRunSummary: 'Prepare a new patchset revision after addressing requested changes.', + blockedBy: ['Revise is not yet exposed through act; inspect context and use xyph revise with a new description.'], + allowed: false, + underlyingCommand: `xyph revise ${submission.id}`, + sideEffects: [`create new patchset for ${submission.id}`], + validationCode: 'unsupported-by-action-kernel', + }; + case 'inspect': + return { + kind: 'inspect', + targetId: nextStep.targetId, + args: {}, + reason: nextStep.reason, + confidence: 0.78, + requiresHumanApproval: false, + dryRunSummary: 'Inspect quest and submission context before taking a follow-on action.', + blockedBy: [], + allowed: true, + underlyingCommand: `xyph context ${nextStep.targetId}`, + sideEffects: [], + validationCode: null, + }; + case 'wait': + default: + return null; + } + } } diff --git a/src/domain/services/AgentRecommender.ts b/src/domain/services/AgentRecommender.ts index 53d8b82..caddf29 100644 --- a/src/domain/services/AgentRecommender.ts +++ b/src/domain/services/AgentRecommender.ts @@ -44,7 +44,10 @@ interface CandidateSeed { } export class AgentRecommender { - constructor(private readonly validator: AgentActionValidator) {} + constructor( + private readonly validator: AgentActionValidator, + private readonly agentId: string, + ) {} public async recommendForQuest( quest: QuestNode, @@ -53,7 +56,14 @@ export class AgentRecommender { ): Promise { const seeds: CandidateSeed[] = []; - if (quest.status === 'READY' && dependency.isFrontier) { + if ( + quest.status === 'READY' && + dependency.isFrontier && + ( + quest.assignedTo === undefined || + quest.assignedTo === this.agentId + ) + ) { seeds.push({ request: { kind: 'claim', diff --git a/src/infrastructure/adapters/WarpSubmissionAdapter.ts b/src/infrastructure/adapters/WarpSubmissionAdapter.ts index bade3e2..800f38c 100644 --- a/src/infrastructure/adapters/WarpSubmissionAdapter.ts +++ b/src/infrastructure/adapters/WarpSubmissionAdapter.ts @@ -256,6 +256,30 @@ export class WarpSubmissionAdapter implements SubmissionPort, SubmissionReadMode return typeof workspaceRef === 'string' ? workspaceRef : null; } + public async getPatchsetMergeRef(patchsetId: string): Promise { + const graph = await this.graphPort.getGraph(); + const props = await graph.getNodeProps(patchsetId); + if (!props) return null; + + const headRef = props['head_ref']; + if (typeof headRef === 'string' && headRef.trim().length > 0) { + return headRef.trim(); + } + + const commitShas = props['commit_shas']; + if (typeof commitShas === 'string') { + const firstRecordedCommit = commitShas + .split(',') + .map((entry) => entry.trim()) + .find((entry) => entry.length > 0); + if (firstRecordedCommit) { + return firstRecordedCommit; + } + } + + return null; + } + public async getReviewsForPatchset(patchsetId: string): Promise { const graph = await this.graphPort.getGraph(); const reviewNeighbors = toNeighborEntries( diff --git a/test/unit/AgentActionService.test.ts b/test/unit/AgentActionService.test.ts index 9834138..41c1e53 100644 --- a/test/unit/AgentActionService.test.ts +++ b/test/unit/AgentActionService.test.ts @@ -16,6 +16,7 @@ const mocks = vi.hoisted(() => ({ getSubmissionForPatchset: vi.fn(), getOpenSubmissionsForQuest: vi.fn(), getPatchsetWorkspaceRef: vi.fn(), + getPatchsetMergeRef: vi.fn(), getSubmissionQuestId: vi.fn(), getQuestStatus: vi.fn(), getWorkspaceRef: vi.fn(), @@ -105,6 +106,10 @@ vi.mock('../../src/infrastructure/adapters/WarpSubmissionAdapter.js', () => ({ return mocks.getPatchsetWorkspaceRef(patchsetId); } + getPatchsetMergeRef(patchsetId: string) { + return mocks.getPatchsetMergeRef(patchsetId); + } + getSubmissionQuestId(submissionId: string) { return mocks.getSubmissionQuestId(submissionId); } @@ -256,6 +261,7 @@ describe('AgentActionService', () => { mocks.getSubmissionForPatchset.mockResolvedValue('submission:AGT-001'); mocks.getOpenSubmissionsForQuest.mockResolvedValue([]); mocks.getPatchsetWorkspaceRef.mockResolvedValue('feat/agent-action-kernel-v1'); + mocks.getPatchsetMergeRef.mockResolvedValue('abc123def456'); mocks.getSubmissionQuestId.mockResolvedValue('task:AGT-001'); mocks.getQuestStatus.mockResolvedValue('READY'); mocks.getWorkspaceRef.mockResolvedValue('feat/agent-action-kernel-v1'); @@ -326,6 +332,33 @@ describe('AgentActionService', () => { ]); }); + it('rejects claim when the READY quest is assigned to another principal', async () => { + const service = new AgentActionService( + makeGraphPort({}), + makeRoadmap(makeQuest({ status: 'READY', assignedTo: 'agent.other' })), + 'agent.hal', + ); + + const outcome = await service.execute({ + kind: 'claim', + targetId: 'task:AGT-001', + dryRun: true, + args: {}, + }); + + expect(outcome).toMatchObject({ + kind: 'claim', + targetId: 'task:AGT-001', + allowed: false, + result: 'rejected', + validation: { + valid: false, + code: 'already-assigned', + }, + }); + expect(outcome.validation.reasons[0]).toContain('assigned to agent.other'); + }); + it('normalizes packet creation during dry-run without mutating the graph', async () => { const graph = { hasNode: vi.fn(async (id: string) => id === 'task:AGT-001'), @@ -650,6 +683,7 @@ describe('AgentActionService', () => { rationale: 'Independent review is complete and the tip is approved.', intoRef: 'main', tipPatchsetId: 'patchset:tip', + mergeRef: 'abc123def456', questId: 'task:AGT-001', shouldAutoSeal: true, workspaceRef: 'feat/agent-action-kernel-v1', @@ -685,7 +719,7 @@ describe('AgentActionService', () => { }, }); - expect(mocks.merge).toHaveBeenCalledWith('feat/agent-action-kernel-v1', 'main'); + expect(mocks.merge).toHaveBeenCalledWith('abc123def456', 'main'); expect(mocks.decide).toHaveBeenCalledWith(expect.objectContaining({ submissionId: 'submission:AGT-001', kind: 'merge', @@ -729,7 +763,7 @@ describe('AgentActionService', () => { }, }); - expect(mocks.merge).toHaveBeenCalledWith('feat/agent-action-kernel-v1', 'main'); + expect(mocks.merge).toHaveBeenCalledWith('abc123def456', 'main'); expect(outcome).toMatchObject({ kind: 'merge', targetId: 'submission:AGT-001', diff --git a/test/unit/AgentContextService.test.ts b/test/unit/AgentContextService.test.ts index 669f384..762c250 100644 --- a/test/unit/AgentContextService.test.ts +++ b/test/unit/AgentContextService.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { Quest } from '../../src/domain/entities/Quest.js'; import type { RoadmapQueryPort } from '../../src/ports/RoadmapPort.js'; import type { GraphPort } from '../../src/ports/GraphPort.js'; -import { makeSnapshot, quest, campaign, intent } from '../helpers/snapshot.js'; +import { makeSnapshot, quest, campaign, intent, submission } from '../helpers/snapshot.js'; import { AgentContextService } from '../../src/domain/services/AgentContextService.js'; const mocks = vi.hoisted(() => ({ @@ -264,4 +264,111 @@ describe('AgentContextService', () => { allowed: true, }); }); + + it('includes submission-driven actions in quest context when review workflow is the real next move', async () => { + const snapshot = makeSnapshot({ + quests: [ + quest({ + id: 'task:CTX-MERGE', + title: 'Quest awaiting settlement', + status: 'IN_PROGRESS', + hours: 2, + description: 'Quest has already been submitted and approved.', + taskKind: 'delivery', + campaignId: 'campaign:TRACE', + intentId: 'intent:TRACE', + }), + ], + campaigns: [campaign({ id: 'campaign:TRACE', title: 'Trace Campaign' })], + intents: [intent({ id: 'intent:TRACE', title: 'Trace Intent' })], + submissions: [ + submission({ + id: 'submission:CTX-MERGE', + questId: 'task:CTX-MERGE', + status: 'APPROVED', + submittedBy: 'agent.hal', + submittedAt: Date.UTC(2026, 2, 13, 1, 0, 0), + tipPatchsetId: 'patchset:CTX-MERGE', + approvalCount: 1, + }), + ], + sortedTaskIds: ['task:CTX-MERGE'], + }); + + const detail = { + id: 'task:CTX-MERGE', + type: 'task', + props: { type: 'task', title: 'Quest awaiting settlement' }, + content: null, + contentOid: null, + outgoing: [], + incoming: [], + questDetail: { + id: 'task:CTX-MERGE', + quest: snapshot.quests[0] ?? (() => { throw new Error('missing quest fixture'); })(), + campaign: snapshot.campaigns[0], + intent: snapshot.intents[0], + submission: snapshot.submissions[0], + reviews: [], + decisions: [], + stories: [], + requirements: [], + criteria: [], + evidence: [], + policies: [], + documents: [], + comments: [], + timeline: [], + }, + }; + + mocks.createGraphContext.mockReturnValue({ + fetchSnapshot: vi.fn().mockResolvedValue(snapshot), + fetchEntityDetail: vi.fn().mockResolvedValue(detail), + filterSnapshot: vi.fn(), + invalidateCache: vi.fn(), + get graph() { + throw new Error('not used in test'); + }, + }); + + const service = new AgentContextService( + makeGraphPort(), + makeRoadmap( + makeQuestEntity({ + id: 'task:CTX-MERGE', + title: 'Quest awaiting settlement', + status: 'IN_PROGRESS', + hours: 2, + description: 'Quest has already been submitted and approved.', + }), + { + 'task:CTX-MERGE': [ + { type: 'authorized-by', to: 'intent:TRACE' }, + { type: 'belongs-to', to: 'campaign:TRACE' }, + { type: 'implements', to: 'req:CTX-MERGE' }, + ], + 'req:CTX-MERGE': [ + { type: 'has-criterion', to: 'criterion:CTX-MERGE' }, + ], + }, + { + 'req:CTX-MERGE': [ + { type: 'decomposes-to', from: 'story:CTX-MERGE' }, + ], + }, + ), + 'agent.hal', + ); + + const result = await service.fetch('task:CTX-MERGE'); + + expect(result?.recommendedActions).toEqual(expect.arrayContaining([ + expect.objectContaining({ + kind: 'merge', + targetId: 'submission:CTX-MERGE', + validationCode: 'requires-additional-input', + }), + ])); + }); }); diff --git a/test/unit/SignedSettlementCommands.test.ts b/test/unit/SignedSettlementCommands.test.ts index 3031841..854dbe9 100644 --- a/test/unit/SignedSettlementCommands.test.ts +++ b/test/unit/SignedSettlementCommands.test.ts @@ -18,6 +18,7 @@ const mocks = vi.hoisted(() => ({ validateMerge: vi.fn(), submit: vi.fn(), getPatchsetWorkspaceRef: vi.fn(), + getPatchsetMergeRef: vi.fn(), getSubmissionQuestId: vi.fn(), getQuestStatus: vi.fn(), decide: vi.fn(), @@ -65,6 +66,10 @@ vi.mock('../../src/infrastructure/adapters/WarpSubmissionAdapter.js', () => ({ return mocks.getPatchsetWorkspaceRef(id); } + getPatchsetMergeRef(id: string): Promise { + return mocks.getPatchsetMergeRef(id); + } + getSubmissionQuestId(id: string): Promise { return mocks.getSubmissionQuestId(id); } @@ -254,6 +259,7 @@ describe('signed settlement enforcement', () => { mocks.getWorkspaceRef.mockResolvedValue('feat/current'); mocks.getCommitsSince.mockResolvedValue(['abc123def4567890']); mocks.getPatchsetWorkspaceRef.mockResolvedValue('feature/quest'); + mocks.getPatchsetMergeRef.mockResolvedValue('feedfacecafebeef'); mocks.getSubmissionQuestId.mockResolvedValue('task:Q1'); mocks.getQuestStatus.mockResolvedValue('PLANNED'); mocks.decide.mockResolvedValue({ patchSha: 'patch:decision' }); @@ -507,6 +513,19 @@ describe('signed settlement enforcement', () => { }); }); + it('merge settles the approved patchset head, not the mutable workspace branch ref', async () => { + const program = new Command(); + registerSubmissionCommands(program, createJsonCtx()); + + await program.parseAsync( + ['merge', 'submission:S1', '--rationale', 'merge the approved patchset tip'], + { from: 'user' }, + ); + + expect(mocks.isMerged).toHaveBeenCalledWith('feedfacecafebeef', 'main'); + expect(mocks.merge).toHaveBeenCalledWith('feedfacecafebeef', 'main'); + }); + it('merge fails before git settlement when governed completion is incomplete', async () => { mocks.fetchEntityDetail.mockResolvedValue(makeQuestDetail({ quest: { From 98d443d8134b2d7e61b91da641b2867b5ed149de Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 13 Mar 2026 15:40:14 -0700 Subject: [PATCH 19/22] Add doctor graph health audit command --- CHANGELOG.md | 1 + src/cli/commands/doctor.ts | 86 ++++ src/domain/services/DoctorService.ts | 589 +++++++++++++++++++++++++++ test/unit/DoctorCommands.test.ts | 183 +++++++++ test/unit/DoctorService.test.ts | 411 +++++++++++++++++++ xyph-actuator.ts | 2 + 6 files changed, 1272 insertions(+) create mode 100644 src/cli/commands/doctor.ts create mode 100644 src/domain/services/DoctorService.ts create mode 100644 test/unit/DoctorCommands.test.ts create mode 100644 test/unit/DoctorService.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c96e19..20054a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to XYPH will be documented in this file. ### Added +- **`xyph doctor` graph health audit** — new CLI command audits dangling edges (including incoming edges from missing nodes), orphaned workflow/narrative/traceability nodes, readiness contract gaps, sovereignty violations, and governed completion gaps. Supports both human-readable output and `--json` for automation - **Global "My Stuff" drawer** — press `m` from any screen to toggle an animated drawer showing agent's quests, submissions, and recent activity. Slides in from the right with a tween animation; content is agent-scoped when `XYPH_AGENT_ID` is set. Replaces the fixed right column on the dashboard view - **Campaign DAG visualization** — campaigns with inter-campaign dependencies are rendered as a mini-DAG using bijou's `dagLayout()`, sorted topologically. Falls back to flat list when no dependencies exist - **Status bar progress bar** — compact gradient progress bar added to the status line showing quest completion percentage diff --git a/src/cli/commands/doctor.ts b/src/cli/commands/doctor.ts new file mode 100644 index 0000000..4fe1216 --- /dev/null +++ b/src/cli/commands/doctor.ts @@ -0,0 +1,86 @@ +import type { Command } from 'commander'; +import type { CliContext } from '../context.js'; +import { createErrorHandler } from '../errorHandler.js'; +import { DoctorService, type DoctorReport } from '../../domain/services/DoctorService.js'; +import { WarpRoadmapAdapter } from '../../infrastructure/adapters/WarpRoadmapAdapter.js'; + +function renderDoctorReport(report: DoctorReport): string { + const lines: string[] = []; + lines.push(`XYPH Doctor [${report.status.toUpperCase()}]`); + lines.push(`As Of: ${new Date(report.asOf).toISOString()}`); + + if (report.graphMeta) { + lines.push(`Graph: tick=${report.graphMeta.maxTick} writers=${report.graphMeta.writerCount} tip=${report.graphMeta.tipSha}`); + } + + lines.push(''); + lines.push('Counts'); + lines.push(` quests=${report.counts.quests} campaigns=${report.counts.campaigns} intents=${report.counts.intents}`); + lines.push(` submissions=${report.counts.submissions} patchsets=${report.counts.patchsets} reviews=${report.counts.reviews} decisions=${report.counts.decisions}`); + lines.push(` stories=${report.counts.stories} requirements=${report.counts.requirements} criteria=${report.counts.criteria} evidence=${report.counts.evidence} policies=${report.counts.policies}`); + lines.push(` scrolls=${report.counts.scrolls} docs=${report.counts.documents} comments=${report.counts.comments}`); + + lines.push(''); + lines.push('Summary'); + lines.push(` issues=${report.summary.issueCount} errors=${report.summary.errorCount} warnings=${report.summary.warningCount}`); + lines.push(` danglingEdges=${report.summary.danglingEdges} orphanNodes=${report.summary.orphanNodes}`); + lines.push(` readinessGaps=${report.summary.readinessGaps} sovereigntyViolations=${report.summary.sovereigntyViolations} governedCompletionGaps=${report.summary.governedCompletionGaps}`); + + if (report.issues.length === 0) { + lines.push(''); + lines.push('No issues found.'); + return lines.join('\n'); + } + + lines.push(''); + lines.push('Issues'); + for (const issue of report.issues) { + const related = issue.relatedIds.length > 0 + ? ` [${issue.relatedIds.join(', ')}]` + : ''; + lines.push(` [${issue.severity.toUpperCase()}] ${issue.code}${issue.nodeId ? ` ${issue.nodeId}` : ''}${related}`); + lines.push(` ${issue.message}`); + } + + return lines.join('\n'); +} + +export function registerDoctorCommands(program: Command, ctx: CliContext): void { + const withErrorHandler = createErrorHandler(ctx); + + program + .command('doctor') + .description('Audit graph health, structural integrity, and workflow gaps') + .action(withErrorHandler(async () => { + const service = new DoctorService( + ctx.graphPort, + new WarpRoadmapAdapter(ctx.graphPort), + ); + const report = await service.run(); + + if (ctx.json) { + if (report.blocking) { + return ctx.failWithData( + `${report.summary.errorCount} blocking graph health issue(s) detected`, + report as unknown as Record, + ); + } + ctx.jsonOut({ + success: true, + command: 'doctor', + data: report as unknown as Record, + }); + return; + } + + const rendered = renderDoctorReport(report); + if (report.blocking) { + return ctx.fail(rendered); + } + if (!report.healthy) { + ctx.warn(rendered); + return; + } + ctx.ok(rendered); + })); +} diff --git a/src/domain/services/DoctorService.ts b/src/domain/services/DoctorService.ts new file mode 100644 index 0000000..8cf53a9 --- /dev/null +++ b/src/domain/services/DoctorService.ts @@ -0,0 +1,589 @@ +import type WarpGraph from '@git-stunts/git-warp'; +import type { QueryResultV1, AggregateResult } from '@git-stunts/git-warp'; +import type { GraphPort } from '../../ports/GraphPort.js'; +import type { RoadmapQueryPort } from '../../ports/RoadmapPort.js'; +import type { GraphMeta, GraphSnapshot } from '../models/dashboard.js'; +import { createGraphContext } from '../../infrastructure/GraphContext.js'; +import { toNeighborEntries, type NeighborEntry } from '../../infrastructure/helpers/isNeighborEntry.js'; +import { ReadinessService } from './ReadinessService.js'; +import { + SovereigntyService, + SOVEREIGNTY_AUDIT_STATUSES, +} from './SovereigntyService.js'; + +type DoctorIssueBucket = + | 'dangling-edge' + | 'orphan-node' + | 'readiness-gap' + | 'sovereignty-violation' + | 'governed-completion-gap'; + +export type DoctorIssueSeverity = 'error' | 'warning'; +export type DoctorStatus = 'ok' | 'warn' | 'error'; + +interface QNode { + id: string; + props: Record; +} + +interface NarrativeAuditNode { + id: string; + type: 'spec' | 'adr' | 'note'; + title: string; + targetIds: string[]; +} + +interface CommentAuditNode { + id: string; + targetId?: string; + replyToId?: string; +} + +export interface DoctorIssue { + bucket: DoctorIssueBucket; + severity: DoctorIssueSeverity; + code: string; + message: string; + nodeId?: string; + relatedIds: string[]; +} + +export interface DoctorSummary { + issueCount: number; + errorCount: number; + warningCount: number; + danglingEdges: number; + orphanNodes: number; + readinessGaps: number; + sovereigntyViolations: number; + governedCompletionGaps: number; +} + +export interface DoctorCounts { + campaigns: number; + quests: number; + intents: number; + scrolls: number; + approvals: number; + submissions: number; + patchsets: number; + reviews: number; + decisions: number; + stories: number; + requirements: number; + criteria: number; + evidence: number; + policies: number; + suggestions: number; + documents: number; + comments: number; +} + +export interface DoctorReport { + status: DoctorStatus; + healthy: boolean; + blocking: boolean; + asOf: number; + graphMeta: GraphMeta | null; + auditedStatuses: string[]; + counts: DoctorCounts; + summary: DoctorSummary; + issues: DoctorIssue[]; +} + +function extractNodes(result: QueryResultV1 | AggregateResult): QNode[] { + if (!('nodes' in result)) return []; + return result.nodes.filter( + (node): node is QNode => typeof node.id === 'string' && node.props !== undefined, + ); +} + +async function batchNeighbors( + graph: WarpGraph, + ids: string[], + direction: 'outgoing' | 'incoming' = 'outgoing', +): Promise> { + const map = new Map(); + const results = await Promise.all(ids.map(async (id) => { + const raw = await graph.neighbors(id, direction); + return [id, toNeighborEntries(raw)] as const; + })); + + for (const [id, neighbors] of results) { + map.set(id, neighbors); + } + return map; +} + +async function queryNodeFamily( + graph: WarpGraph, + prefix: string, +): Promise { + return graph.query().match(prefix).select(['id', 'props']).run().then(extractNodes); +} + +export class DoctorService { + private readonly readiness: ReadinessService; + private readonly sovereignty: SovereigntyService; + + constructor( + private readonly graphPort: GraphPort, + roadmap: RoadmapQueryPort, + ) { + this.readiness = new ReadinessService(roadmap); + this.sovereignty = new SovereigntyService(roadmap); + } + + public async run(): Promise { + const graphCtx = createGraphContext(this.graphPort); + const snapshot = await graphCtx.fetchSnapshot(); + const graph = graphCtx.graph; + + const [patchsetNodes, specNodes, adrNodes, noteNodes, commentNodes] = await Promise.all([ + queryNodeFamily(graph, 'patchset:*'), + queryNodeFamily(graph, 'spec:*'), + queryNodeFamily(graph, 'adr:*'), + queryNodeFamily(graph, 'note:*'), + queryNodeFamily(graph, 'comment:*'), + ]); + + const patchsetIds = new Set(patchsetNodes.map((node) => node.id)); + const questIds = new Set(snapshot.quests.map((quest) => quest.id)); + const campaignIds = new Set(snapshot.campaigns.map((campaign) => campaign.id)); + const submissionIds = new Set(snapshot.submissions.map((submission) => submission.id)); + const storyIds = new Set(snapshot.stories.map((story) => story.id)); + const requirementIds = new Set(snapshot.requirements.map((requirement) => requirement.id)); + const narrativeIds = new Set([...specNodes, ...adrNodes, ...noteNodes].map((node) => node.id)); + const commentIds = new Set(commentNodes.map((node) => node.id)); + + const allKnownIds = [...new Set([ + ...snapshot.campaigns.map((node) => node.id), + ...snapshot.quests.map((node) => node.id), + ...snapshot.intents.map((node) => node.id), + ...snapshot.scrolls.map((node) => node.id), + ...snapshot.approvals.map((node) => node.id), + ...snapshot.submissions.map((node) => node.id), + ...patchsetNodes.map((node) => node.id), + ...snapshot.reviews.map((node) => node.id), + ...snapshot.decisions.map((node) => node.id), + ...snapshot.stories.map((node) => node.id), + ...snapshot.requirements.map((node) => node.id), + ...snapshot.criteria.map((node) => node.id), + ...snapshot.evidence.map((node) => node.id), + ...snapshot.policies.map((node) => node.id), + ...snapshot.suggestions.map((node) => node.id), + ...[...narrativeIds], + ...[...commentIds], + ])]; + + const outgoingNeighbors = await batchNeighbors(graph, allKnownIds, 'outgoing'); + const incomingNeighbors = await batchNeighbors(graph, allKnownIds, 'incoming'); + const hasNodeCache = new Map(); + const hasNode = async (id: string): Promise => { + const cached = hasNodeCache.get(id); + if (cached !== undefined) return cached; + const value = await graph.hasNode(id); + hasNodeCache.set(id, value); + return value; + }; + + const issues: DoctorIssue[] = []; + const issueKeys = new Set(); + const pushIssue = (issue: DoctorIssue): void => { + const key = [ + issue.bucket, + issue.code, + issue.nodeId ?? '', + ...issue.relatedIds.slice().sort(), + ].join('|'); + if (issueKeys.has(key)) return; + issueKeys.add(key); + issues.push(issue); + }; + + await this.collectDanglingEdges(allKnownIds, outgoingNeighbors, incomingNeighbors, hasNode, pushIssue); + this.collectNarrativeOrphans(specNodes, adrNodes, noteNodes, commentNodes, outgoingNeighbors, pushIssue); + this.collectWorkflowOrphans(snapshot, patchsetNodes, outgoingNeighbors, questIds, submissionIds, patchsetIds, pushIssue); + this.collectTraceabilityOrphans(snapshot, storyIds, requirementIds, campaignIds, pushIssue); + await this.collectReadinessGaps(snapshot, pushIssue); + await this.collectSovereigntyViolations(pushIssue); + this.collectGovernedCompletionGaps(snapshot, pushIssue); + + issues.sort((a, b) => + Number(a.severity === 'warning') - Number(b.severity === 'warning') || + a.bucket.localeCompare(b.bucket) || + (a.nodeId ?? '').localeCompare(b.nodeId ?? '') || + a.code.localeCompare(b.code) + ); + + const errorCount = issues.filter((issue) => issue.severity === 'error').length; + const warningCount = issues.length - errorCount; + const counts: DoctorCounts = { + campaigns: snapshot.campaigns.length, + quests: snapshot.quests.length, + intents: snapshot.intents.length, + scrolls: snapshot.scrolls.length, + approvals: snapshot.approvals.length, + submissions: snapshot.submissions.length, + patchsets: patchsetNodes.length, + reviews: snapshot.reviews.length, + decisions: snapshot.decisions.length, + stories: snapshot.stories.length, + requirements: snapshot.requirements.length, + criteria: snapshot.criteria.length, + evidence: snapshot.evidence.length, + policies: snapshot.policies.length, + suggestions: snapshot.suggestions.length, + documents: specNodes.length + adrNodes.length + noteNodes.length, + comments: commentNodes.length, + }; + const summary: DoctorSummary = { + issueCount: issues.length, + errorCount, + warningCount, + danglingEdges: issues.filter((issue) => issue.bucket === 'dangling-edge').length, + orphanNodes: issues.filter((issue) => issue.bucket === 'orphan-node').length, + readinessGaps: issues.filter((issue) => issue.bucket === 'readiness-gap').length, + sovereigntyViolations: issues.filter((issue) => issue.bucket === 'sovereignty-violation').length, + governedCompletionGaps: issues.filter((issue) => issue.bucket === 'governed-completion-gap').length, + }; + + const status: DoctorStatus = errorCount > 0 + ? 'error' + : warningCount > 0 + ? 'warn' + : 'ok'; + + return { + status, + healthy: issues.length === 0, + blocking: errorCount > 0, + asOf: snapshot.asOf, + graphMeta: snapshot.graphMeta ?? null, + auditedStatuses: [...SOVEREIGNTY_AUDIT_STATUSES], + counts, + summary, + issues, + }; + } + + private async collectDanglingEdges( + nodeIds: string[], + outgoingNeighbors: Map, + incomingNeighbors: Map, + hasNode: (id: string) => Promise, + pushIssue: (issue: DoctorIssue) => void, + ): Promise { + for (const nodeId of nodeIds) { + for (const edge of outgoingNeighbors.get(nodeId) ?? []) { + if (await hasNode(edge.nodeId)) continue; + pushIssue({ + bucket: 'dangling-edge', + severity: 'error', + code: `dangling-outgoing-${edge.label}`, + message: `${nodeId} has an outgoing ${edge.label} edge to missing node ${edge.nodeId}`, + nodeId, + relatedIds: [edge.nodeId], + }); + } + for (const edge of incomingNeighbors.get(nodeId) ?? []) { + if (await hasNode(edge.nodeId)) continue; + pushIssue({ + bucket: 'dangling-edge', + severity: 'error', + code: `dangling-incoming-${edge.label}`, + message: `${nodeId} has an incoming ${edge.label} edge from missing node ${edge.nodeId}`, + nodeId, + relatedIds: [edge.nodeId], + }); + } + } + } + + private collectNarrativeOrphans( + specNodes: QNode[], + adrNodes: QNode[], + noteNodes: QNode[], + commentNodes: QNode[], + outgoingNeighbors: Map, + pushIssue: (issue: DoctorIssue) => void, + ): void { + const documents: NarrativeAuditNode[] = [...specNodes, ...adrNodes, ...noteNodes].flatMap((node) => { + const rawType = node.props['type']; + const title = node.props['title']; + if ( + (rawType !== 'spec' && rawType !== 'adr' && rawType !== 'note') || + typeof title !== 'string' + ) { + return []; + } + const targetIds = (outgoingNeighbors.get(node.id) ?? []) + .filter((edge) => edge.label === 'documents') + .map((edge) => edge.nodeId); + return [{ + id: node.id, + type: rawType, + title, + targetIds, + }]; + }); + + for (const document of documents) { + if (document.targetIds.length > 0) continue; + pushIssue({ + bucket: 'orphan-node', + severity: 'warning', + code: `orphan-${document.type}`, + message: `${document.id} (${document.title}) is not linked to any documented target`, + nodeId: document.id, + relatedIds: [], + }); + } + + const comments: CommentAuditNode[] = commentNodes.map((node) => { + let targetId: string | undefined; + let replyToId: string | undefined; + for (const edge of outgoingNeighbors.get(node.id) ?? []) { + if (edge.label === 'comments-on') targetId = edge.nodeId; + if (edge.label === 'replies-to') replyToId = edge.nodeId; + } + return { + id: node.id, + targetId, + replyToId, + }; + }); + + for (const comment of comments) { + if (comment.targetId || comment.replyToId) continue; + pushIssue({ + bucket: 'orphan-node', + severity: 'warning', + code: 'orphan-comment', + message: `${comment.id} is not attached to a target node or comment thread`, + nodeId: comment.id, + relatedIds: [], + }); + } + } + + private collectWorkflowOrphans( + snapshot: GraphSnapshot, + patchsetNodes: QNode[], + outgoingNeighbors: Map, + questIds: Set, + submissionIds: Set, + patchsetIds: Set, + pushIssue: (issue: DoctorIssue) => void, + ): void { + for (const submission of snapshot.submissions) { + if (questIds.has(submission.questId)) continue; + pushIssue({ + bucket: 'orphan-node', + severity: 'error', + code: 'orphan-submission', + message: `${submission.id} references missing quest ${submission.questId}`, + nodeId: submission.id, + relatedIds: [submission.questId], + }); + } + + for (const patchset of patchsetNodes) { + const submissionId = (outgoingNeighbors.get(patchset.id) ?? []) + .find((edge) => edge.label === 'has-patchset' && edge.nodeId.startsWith('submission:')) + ?.nodeId; + if (submissionId && submissionIds.has(submissionId)) continue; + pushIssue({ + bucket: 'orphan-node', + severity: 'error', + code: 'orphan-patchset', + message: `${patchset.id} is not linked to a valid submission`, + nodeId: patchset.id, + relatedIds: submissionId ? [submissionId] : [], + }); + } + + for (const review of snapshot.reviews) { + if (patchsetIds.has(review.patchsetId)) continue; + pushIssue({ + bucket: 'orphan-node', + severity: 'error', + code: 'orphan-review', + message: `${review.id} references missing patchset ${review.patchsetId}`, + nodeId: review.id, + relatedIds: [review.patchsetId], + }); + } + + for (const decision of snapshot.decisions) { + if (submissionIds.has(decision.submissionId)) continue; + pushIssue({ + bucket: 'orphan-node', + severity: 'error', + code: 'orphan-decision', + message: `${decision.id} references missing submission ${decision.submissionId}`, + nodeId: decision.id, + relatedIds: [decision.submissionId], + }); + } + + for (const scroll of snapshot.scrolls) { + if (questIds.has(scroll.questId)) continue; + pushIssue({ + bucket: 'orphan-node', + severity: 'error', + code: 'orphan-scroll', + message: `${scroll.id} references missing quest ${scroll.questId}`, + nodeId: scroll.id, + relatedIds: [scroll.questId], + }); + } + } + + private collectTraceabilityOrphans( + snapshot: GraphSnapshot, + storyIds: Set, + requirementIds: Set, + campaignIds: Set, + pushIssue: (issue: DoctorIssue) => void, + ): void { + const requirementIdsByStory = new Map(); + for (const requirement of snapshot.requirements) { + if (!requirement.storyId) continue; + const linked = requirementIdsByStory.get(requirement.storyId) ?? []; + linked.push(requirement.id); + requirementIdsByStory.set(requirement.storyId, linked); + } + + for (const story of snapshot.stories) { + const linkedRequirements = requirementIdsByStory.get(story.id) ?? []; + if (story.intentId && linkedRequirements.length > 0) continue; + + const relatedIds = [ + ...(story.intentId ? [story.intentId] : []), + ...linkedRequirements, + ]; + pushIssue({ + bucket: 'orphan-node', + severity: 'warning', + code: 'orphan-story', + message: `${story.id} is missing intent lineage or requirement decomposition`, + nodeId: story.id, + relatedIds, + }); + } + + for (const requirement of snapshot.requirements) { + if (requirement.storyId && storyIds.has(requirement.storyId)) continue; + if (requirement.taskIds.length > 0) continue; + + pushIssue({ + bucket: 'orphan-node', + severity: 'warning', + code: 'orphan-requirement', + message: `${requirement.id} is not linked to a story or implementing quest`, + nodeId: requirement.id, + relatedIds: [], + }); + } + + for (const criterion of snapshot.criteria) { + if (criterion.requirementId && requirementIds.has(criterion.requirementId)) continue; + pushIssue({ + bucket: 'orphan-node', + severity: 'warning', + code: 'orphan-criterion', + message: `${criterion.id} is not linked to a requirement`, + nodeId: criterion.id, + relatedIds: criterion.requirementId ? [criterion.requirementId] : [], + }); + } + + for (const evidence of snapshot.evidence) { + if (evidence.criterionId || evidence.requirementId) continue; + pushIssue({ + bucket: 'orphan-node', + severity: 'warning', + code: 'orphan-evidence', + message: `${evidence.id} is not linked to a criterion or requirement`, + nodeId: evidence.id, + relatedIds: [], + }); + } + + for (const policy of snapshot.policies) { + if (policy.campaignId && campaignIds.has(policy.campaignId)) continue; + pushIssue({ + bucket: 'orphan-node', + severity: 'warning', + code: 'orphan-policy', + message: `${policy.id} is not linked to a governed campaign`, + nodeId: policy.id, + relatedIds: policy.campaignId ? [policy.campaignId] : [], + }); + } + } + + private async collectReadinessGaps( + snapshot: GraphSnapshot, + pushIssue: (issue: DoctorIssue) => void, + ): Promise { + const candidates = snapshot.quests.filter((quest) => + quest.status !== 'BACKLOG' && quest.status !== 'GRAVEYARD', + ); + + for (const quest of candidates) { + const assessment = await this.readiness.assess(quest.id, { transition: false }); + if (assessment.valid) continue; + pushIssue({ + bucket: 'readiness-gap', + severity: 'warning', + code: 'quest-readiness-gap', + message: `${quest.id} fails the readiness contract: ${assessment.unmet.map((item) => item.message).join(' | ')}`, + nodeId: quest.id, + relatedIds: assessment.unmet + .map((item) => item.nodeId) + .filter((nodeId): nodeId is string => typeof nodeId === 'string'), + }); + } + } + + private async collectSovereigntyViolations( + pushIssue: (issue: DoctorIssue) => void, + ): Promise { + const violations = await this.sovereignty.auditAuthorizedWork(); + for (const violation of violations) { + pushIssue({ + bucket: 'sovereignty-violation', + severity: 'warning', + code: 'missing-intent-ancestry', + message: `${violation.questId} lacks sovereign intent ancestry: ${violation.reason}`, + nodeId: violation.questId, + relatedIds: [], + }); + } + } + + private collectGovernedCompletionGaps( + snapshot: GraphSnapshot, + pushIssue: (issue: DoctorIssue) => void, + ): void { + for (const quest of snapshot.quests) { + const completion = quest.computedCompletion; + if (!completion?.policyId || completion.complete) continue; + pushIssue({ + bucket: 'governed-completion-gap', + severity: 'warning', + code: 'governed-quest-incomplete', + message: `${quest.id} is governed by ${completion.policyId} but computed completion is ${completion.verdict}`, + nodeId: quest.id, + relatedIds: [ + completion.policyId, + ...completion.failingCriterionIds, + ...completion.linkedOnlyCriterionIds, + ...completion.missingCriterionIds, + ], + }); + } + } +} diff --git a/test/unit/DoctorCommands.test.ts b/test/unit/DoctorCommands.test.ts new file mode 100644 index 0000000..2b95d65 --- /dev/null +++ b/test/unit/DoctorCommands.test.ts @@ -0,0 +1,183 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { Command } from 'commander'; +import type { CliContext } from '../../src/cli/context.js'; + +const runDoctor = vi.fn(); +const doctorCtor = vi.fn(); +const roadmapCtor = vi.fn(); + +vi.mock('../../src/domain/services/DoctorService.js', () => ({ + DoctorService: vi.fn().mockImplementation(function MockDoctorService(graphPort, roadmap) { + doctorCtor(graphPort, roadmap); + return { + run: runDoctor, + }; + }), +})); + +vi.mock('../../src/infrastructure/adapters/WarpRoadmapAdapter.js', () => ({ + WarpRoadmapAdapter: vi.fn().mockImplementation(function MockWarpRoadmapAdapter(graphPort) { + roadmapCtor(graphPort); + return { mocked: true }; + }), +})); + +import { registerDoctorCommands } from '../../src/cli/commands/doctor.js'; + +function makeCtx(json: boolean): CliContext { + return { + agentId: 'human.audit', + identity: { agentId: 'human.audit', source: 'default', origin: null }, + json, + graphPort: {} as CliContext['graphPort'], + style: {} as CliContext['style'], + ok: vi.fn(), + warn: vi.fn(), + muted: vi.fn(), + print: vi.fn(), + fail: vi.fn((msg: string) => { + throw new Error(msg); + }), + failWithData: vi.fn(), + jsonOut: vi.fn(), + } as unknown as CliContext; +} + +function registerDoctor(ctx: CliContext): Command { + const program = new Command(); + registerDoctorCommands(program, ctx); + return program; +} + +describe('doctor command', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('describes the graph health audit', () => { + const program = registerDoctor(makeCtx(true)); + const cmd = program.commands.find((command) => command.name() === 'doctor'); + + expect(cmd?.description()).toBe( + 'Audit graph health, structural integrity, and workflow gaps', + ); + }); + + it('emits the doctor report in JSON mode when only warnings are present', async () => { + const report = { + status: 'warn', + healthy: false, + blocking: false, + asOf: 123, + graphMeta: null, + auditedStatuses: ['PLANNED', 'READY'], + counts: { + campaigns: 1, + quests: 2, + intents: 1, + scrolls: 0, + approvals: 0, + submissions: 0, + patchsets: 0, + reviews: 0, + decisions: 0, + stories: 0, + requirements: 0, + criteria: 0, + evidence: 0, + policies: 0, + suggestions: 0, + documents: 0, + comments: 0, + }, + summary: { + issueCount: 1, + errorCount: 0, + warningCount: 1, + danglingEdges: 0, + orphanNodes: 1, + readinessGaps: 0, + sovereigntyViolations: 0, + governedCompletionGaps: 0, + }, + issues: [ + { + bucket: 'orphan-node', + severity: 'warning', + code: 'orphan-comment', + message: 'comment:1 is not attached', + nodeId: 'comment:1', + relatedIds: [], + }, + ], + }; + runDoctor.mockResolvedValueOnce(report); + + const ctx = makeCtx(true); + const program = registerDoctor(ctx); + + await program.parseAsync(['doctor'], { from: 'user' }); + + expect(roadmapCtor).toHaveBeenCalledWith(ctx.graphPort); + expect(doctorCtor).toHaveBeenCalledWith(ctx.graphPort, { mocked: true }); + expect(ctx.jsonOut).toHaveBeenCalledWith({ + success: true, + command: 'doctor', + data: report, + }); + expect(ctx.failWithData).not.toHaveBeenCalled(); + }); + + it('reports blocking issues through the JSON error envelope', async () => { + const report = { + status: 'error', + healthy: false, + blocking: true, + asOf: 123, + graphMeta: null, + auditedStatuses: ['PLANNED', 'READY'], + counts: { + campaigns: 1, + quests: 1, + intents: 0, + scrolls: 0, + approvals: 0, + submissions: 0, + patchsets: 0, + reviews: 0, + decisions: 0, + stories: 0, + requirements: 0, + criteria: 0, + evidence: 0, + policies: 0, + suggestions: 0, + documents: 0, + comments: 0, + }, + summary: { + issueCount: 2, + errorCount: 2, + warningCount: 0, + danglingEdges: 1, + orphanNodes: 1, + readinessGaps: 0, + sovereigntyViolations: 0, + governedCompletionGaps: 0, + }, + issues: [], + }; + runDoctor.mockResolvedValueOnce(report); + + const ctx = makeCtx(true); + const program = registerDoctor(ctx); + + await program.parseAsync(['doctor'], { from: 'user' }); + + expect(ctx.failWithData).toHaveBeenCalledWith( + '2 blocking graph health issue(s) detected', + report as unknown as Record, + ); + expect(ctx.jsonOut).not.toHaveBeenCalled(); + }); +}); diff --git a/test/unit/DoctorService.test.ts b/test/unit/DoctorService.test.ts new file mode 100644 index 0000000..4ce6fdf --- /dev/null +++ b/test/unit/DoctorService.test.ts @@ -0,0 +1,411 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { Quest } from '../../src/domain/entities/Quest.js'; +import type { GraphPort } from '../../src/ports/GraphPort.js'; +import type { RoadmapQueryPort } from '../../src/ports/RoadmapPort.js'; +import { makeSnapshot, campaign, quest, submission, review, decision, scroll } from '../helpers/snapshot.js'; +import { DoctorService } from '../../src/domain/services/DoctorService.js'; + +const mocks = vi.hoisted(() => ({ + createGraphContext: vi.fn(), +})); + +vi.mock('../../src/infrastructure/GraphContext.js', () => ({ + createGraphContext: (graphPort: unknown) => mocks.createGraphContext(graphPort), +})); + +function makeRoadmap( + quests: Quest[], + outgoingByNode: Record = {}, + incomingByNode: Record = {}, +): RoadmapQueryPort { + const byId = new Map(quests.map((item) => [item.id, item] as const)); + return { + getQuests: vi.fn().mockResolvedValue(quests), + getQuest: vi.fn(async (id: string) => byId.get(id) ?? null), + getOutgoingEdges: vi.fn(async (id: string) => outgoingByNode[id] ?? []), + getIncomingEdges: vi.fn(async (id: string) => incomingByNode[id] ?? []), + }; +} + +function makeGraphPort(options: { + queryNodesByPrefix: Record }[]>; + neighborsByDirectionAndId?: Record; + existingIds: string[]; +}): GraphPort { + const existingIds = new Set(options.existingIds); + const graph = { + query: vi.fn(() => ({ + match: vi.fn((prefix: string) => ({ + select: vi.fn(() => ({ + run: vi.fn(async () => ({ + nodes: options.queryNodesByPrefix[prefix] ?? [], + })), + })), + })), + })), + neighbors: vi.fn(async (id: string, direction: string) => ( + options.neighborsByDirectionAndId?.[`${direction}:${id}`] ?? [] + )), + hasNode: vi.fn(async (id: string) => existingIds.has(id)), + }; + + return { + getGraph: vi.fn(async () => graph), + reset: vi.fn(), + }; +} + +describe('DoctorService', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('reports dangling edges, orphans, readiness gaps, sovereignty issues, and governed completion gaps', async () => { + const snapshot = makeSnapshot({ + campaigns: [ + campaign({ id: 'campaign:TRACE', title: 'Trace Campaign' }), + ], + quests: [ + quest({ + id: 'task:READY-GAP', + title: 'Ready gap quest', + status: 'READY', + hours: 2, + taskKind: 'delivery', + }), + quest({ + id: 'task:GOV', + title: 'Governed quest', + status: 'BACKLOG', + hours: 1, + taskKind: 'delivery', + computedCompletion: { + tracked: true, + complete: false, + verdict: 'MISSING', + requirementCount: 1, + criterionCount: 1, + coverageRatio: 0, + satisfiedCount: 0, + failingCriterionIds: [], + linkedOnlyCriterionIds: [], + missingCriterionIds: ['criterion:GOV'], + policyId: 'policy:TRACE', + }, + }), + ], + submissions: [ + submission({ id: 'submission:ORPH', questId: 'task:MISSING' }), + ], + reviews: [ + review({ id: 'review:ORPH', patchsetId: 'patchset:MISSING' }), + ], + decisions: [ + decision({ id: 'decision:ORPH', submissionId: 'submission:MISSING' }), + ], + scrolls: [ + scroll({ id: 'artifact:ORPH', questId: 'task:MISSING-2' }), + ], + stories: [ + { + id: 'story:ORPH', + title: 'Loose story', + persona: 'operator', + goal: 'fix the graph', + benefit: 'the graph stays honest', + createdBy: 'human.audit', + createdAt: 1, + }, + ], + requirements: [ + { + id: 'req:ORPH', + description: 'Document the missing quest packet', + kind: 'functional', + priority: 'must', + taskIds: [], + criterionIds: [], + }, + ], + criteria: [ + { + id: 'criterion:ORPH', + description: 'Criterion exists without a parent requirement', + verifiable: true, + evidenceIds: [], + }, + ], + evidence: [ + { + id: 'evidence:ORPH', + kind: 'test', + result: 'linked', + producedAt: 1, + producedBy: 'agent.audit', + }, + ], + policies: [ + { + id: 'policy:ORPH', + coverageThreshold: 1, + requireAllCriteria: true, + requireEvidence: true, + allowManualSeal: false, + }, + ], + }); + + const graphPort = makeGraphPort({ + queryNodesByPrefix: { + 'patchset:*': [ + { id: 'patchset:ORPH', props: { type: 'patchset', authored_at: 1 } }, + ], + 'spec:*': [], + 'adr:*': [], + 'note:*': [ + { id: 'note:ORPH', props: { type: 'note', title: 'Loose note' } }, + ], + 'comment:*': [ + { id: 'comment:ORPH', props: { type: 'comment' } }, + ], + }, + neighborsByDirectionAndId: { + 'outgoing:task:GOV': [ + { nodeId: 'task:NOWHERE', label: 'depends-on' }, + ], + 'incoming:task:READY-GAP': [ + { nodeId: 'comment:MISSING', label: 'comments-on' }, + ], + }, + existingIds: [ + 'campaign:TRACE', + 'task:READY-GAP', + 'task:GOV', + 'submission:ORPH', + 'patchset:ORPH', + 'review:ORPH', + 'decision:ORPH', + 'artifact:ORPH', + 'story:ORPH', + 'req:ORPH', + 'criterion:ORPH', + 'evidence:ORPH', + 'policy:ORPH', + 'note:ORPH', + 'comment:ORPH', + ], + }); + + mocks.createGraphContext.mockReturnValue({ + fetchSnapshot: vi.fn().mockResolvedValue(snapshot), + fetchEntityDetail: vi.fn(), + filterSnapshot: vi.fn(), + invalidateCache: vi.fn(), + graph: await graphPort.getGraph(), + }); + + const roadmap = makeRoadmap([ + new Quest({ + id: 'task:READY-GAP', + title: 'Ready gap quest', + status: 'READY', + hours: 2, + type: 'task', + }), + new Quest({ + id: 'task:GOV', + title: 'Governed quest', + status: 'BACKLOG', + hours: 1, + description: 'Governed backlog quest', + type: 'task', + }), + ]); + + const report = await new DoctorService(graphPort, roadmap).run(); + + expect(report.status).toBe('error'); + expect(report.blocking).toBe(true); + expect(report.counts.patchsets).toBe(1); + expect(report.counts.documents).toBe(1); + expect(report.counts.comments).toBe(1); + expect(report.summary.danglingEdges).toBe(2); + expect(report.summary.orphanNodes).toBeGreaterThanOrEqual(8); + expect(report.summary.readinessGaps).toBe(1); + expect(report.summary.sovereigntyViolations).toBe(1); + expect(report.summary.governedCompletionGaps).toBe(1); + expect(report.issues).toEqual(expect.arrayContaining([ + expect.objectContaining({ + bucket: 'dangling-edge', + code: 'dangling-outgoing-depends-on', + nodeId: 'task:GOV', + relatedIds: ['task:NOWHERE'], + }), + expect.objectContaining({ + bucket: 'dangling-edge', + code: 'dangling-incoming-comments-on', + nodeId: 'task:READY-GAP', + relatedIds: ['comment:MISSING'], + }), + expect.objectContaining({ + bucket: 'orphan-node', + code: 'orphan-note', + nodeId: 'note:ORPH', + }), + expect.objectContaining({ + bucket: 'orphan-node', + code: 'orphan-comment', + nodeId: 'comment:ORPH', + }), + expect.objectContaining({ + bucket: 'orphan-node', + code: 'orphan-submission', + nodeId: 'submission:ORPH', + }), + expect.objectContaining({ + bucket: 'readiness-gap', + code: 'quest-readiness-gap', + nodeId: 'task:READY-GAP', + }), + expect.objectContaining({ + bucket: 'sovereignty-violation', + code: 'missing-intent-ancestry', + nodeId: 'task:READY-GAP', + }), + expect.objectContaining({ + bucket: 'governed-completion-gap', + code: 'governed-quest-incomplete', + nodeId: 'task:GOV', + relatedIds: ['policy:TRACE', 'criterion:GOV'], + }), + ])); + }); + + it('returns a healthy report when no issues are found', async () => { + const snapshot = makeSnapshot({ + campaigns: [ + campaign({ id: 'campaign:TRACE', title: 'Trace Campaign' }), + ], + quests: [ + quest({ + id: 'task:READY-OK', + title: 'Ready quest', + status: 'READY', + hours: 2, + description: 'Quest is fully shaped and ready.', + taskKind: 'delivery', + }), + ], + stories: [ + { + id: 'story:READY-OK', + title: 'Ready quest story', + persona: 'operator', + goal: 'ship a healthy quest', + benefit: 'the graph stays trustworthy', + createdBy: 'human.audit', + createdAt: 1, + intentId: 'intent:READY-OK', + }, + ], + requirements: [ + { + id: 'req:READY-OK', + description: 'Ready quest requirement', + kind: 'functional', + priority: 'must', + storyId: 'story:READY-OK', + taskIds: ['task:READY-OK'], + criterionIds: ['criterion:READY-OK'], + }, + ], + criteria: [ + { + id: 'criterion:READY-OK', + description: 'Criterion is backed by evidence', + verifiable: true, + requirementId: 'req:READY-OK', + evidenceIds: ['evidence:READY-OK'], + }, + ], + evidence: [ + { + id: 'evidence:READY-OK', + kind: 'test', + result: 'pass', + producedAt: 1, + producedBy: 'agent.audit', + criterionId: 'criterion:READY-OK', + }, + ], + policies: [ + { + id: 'policy:TRACE', + campaignId: 'campaign:TRACE', + coverageThreshold: 1, + requireAllCriteria: true, + requireEvidence: true, + allowManualSeal: false, + }, + ], + }); + + const graphPort = makeGraphPort({ + queryNodesByPrefix: { + 'patchset:*': [], + 'spec:*': [], + 'adr:*': [], + 'note:*': [], + 'comment:*': [], + }, + existingIds: [ + 'campaign:TRACE', + 'task:READY-OK', + ], + }); + + mocks.createGraphContext.mockReturnValue({ + fetchSnapshot: vi.fn().mockResolvedValue(snapshot), + fetchEntityDetail: vi.fn(), + filterSnapshot: vi.fn(), + invalidateCache: vi.fn(), + graph: await graphPort.getGraph(), + }); + + const roadmap = makeRoadmap( + [ + new Quest({ + id: 'task:READY-OK', + title: 'Ready quest', + status: 'READY', + hours: 2, + description: 'Quest is fully shaped and ready.', + type: 'task', + }), + ], + { + 'task:READY-OK': [ + { type: 'authorized-by', to: 'intent:READY-OK' }, + { type: 'belongs-to', to: 'campaign:TRACE' }, + { type: 'implements', to: 'req:READY-OK' }, + ], + 'req:READY-OK': [ + { type: 'has-criterion', to: 'criterion:READY-OK' }, + ], + }, + { + 'req:READY-OK': [ + { type: 'decomposes-to', from: 'story:READY-OK' }, + ], + }, + ); + + const report = await new DoctorService(graphPort, roadmap).run(); + + expect(report.status).toBe('ok'); + expect(report.healthy).toBe(true); + expect(report.blocking).toBe(false); + expect(report.summary.issueCount).toBe(0); + expect(report.issues).toEqual([]); + }); +}); diff --git a/xyph-actuator.ts b/xyph-actuator.ts index 3d25178..1964b6e 100755 --- a/xyph-actuator.ts +++ b/xyph-actuator.ts @@ -17,6 +17,7 @@ import { registerAnalyzeCommands } from './src/cli/commands/analyze.js'; import { registerIdentityCommands } from './src/cli/commands/identity.js'; import { registerShowCommands } from './src/cli/commands/show.js'; import { registerAgentCommands } from './src/cli/commands/agent.js'; +import { registerDoctorCommands } from './src/cli/commands/doctor.js'; // Best-effort pre-scan for --json before Commander parses. // createCliContext() handles theme init internally based on this flag. @@ -55,5 +56,6 @@ registerAnalyzeCommands(program, ctx); registerIdentityCommands(program, ctx); registerShowCommands(program, ctx); registerAgentCommands(program, ctx); +registerDoctorCommands(program, ctx); await program.parseAsync(process.argv); From c644ede0f398c9cbf65a9d546e77c782f4ed7a04 Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 13 Mar 2026 20:38:48 -0700 Subject: [PATCH 20/22] Add structured health diagnostics envelope --- src/cli/commands/agent.ts | 12 ++ src/cli/commands/doctor.ts | 17 +- src/cli/commands/show.ts | 17 +- src/cli/context.ts | 26 ++- src/cli/renderDiagnostics.ts | 21 ++ src/domain/models/diagnostics.ts | 27 +++ src/domain/services/AgentBriefingService.ts | 26 ++- src/domain/services/AgentContextService.ts | 6 + src/domain/services/DiagnosticService.ts | 209 ++++++++++++++++++++ src/domain/services/DoctorService.ts | 7 + test/unit/AgentBriefingService.test.ts | 59 ++++++ test/unit/AgentCommands.test.ts | 6 + test/unit/CliJsonOutput.test.ts | 28 +++ test/unit/DiagnosticService.test.ts | 159 +++++++++++++++ test/unit/DoctorCommands.test.ts | 30 +++ test/unit/DoctorService.test.ts | 13 ++ test/unit/ShowCommands.test.ts | 1 + 17 files changed, 643 insertions(+), 21 deletions(-) create mode 100644 src/cli/renderDiagnostics.ts create mode 100644 src/domain/models/diagnostics.ts create mode 100644 src/domain/services/DiagnosticService.ts create mode 100644 test/unit/DiagnosticService.test.ts diff --git a/src/cli/commands/agent.ts b/src/cli/commands/agent.ts index cc4a31a..d7bdc81 100644 --- a/src/cli/commands/agent.ts +++ b/src/cli/commands/agent.ts @@ -1,6 +1,7 @@ import type { Command } from 'commander'; import type { CliContext } from '../context.js'; import { createErrorHandler } from '../errorHandler.js'; +import { renderDiagnosticsLines } from '../renderDiagnostics.js'; import { VALID_TASK_KINDS } from '../../domain/entities/Quest.js'; import { VALID_REQUIREMENT_KINDS, @@ -19,6 +20,7 @@ import type { import { AgentBriefingService } from '../../domain/services/AgentBriefingService.js'; import { AgentSubmissionService } from '../../domain/services/AgentSubmissionService.js'; import type { ReadinessAssessment } from '../../domain/services/ReadinessService.js'; +import type { Diagnostic } from '../../domain/models/diagnostics.js'; import type { EntityDetail } from '../../domain/models/dashboard.js'; interface ActOptions { @@ -127,6 +129,7 @@ function renderAgentContext( readiness: ReadinessAssessment | null, dependency: AgentDependencyContext | null, recommendedActions: AgentActionCandidate[], + diagnostics: Diagnostic[], ): string { const lines: string[] = []; lines.push(`${detail.id} [${detail.type}]`); @@ -175,6 +178,8 @@ function renderAgentContext( lines.push(` decisions: ${detail.questDetail.decisions.length}`); } + lines.push(...renderDiagnosticsLines(diagnostics)); + lines.push(''); lines.push('Recommended Actions'); if (recommendedActions.length === 0) { @@ -216,6 +221,7 @@ function renderBriefing(briefing: { frontier: { quest: { id: string; title: string; status: string }; nextAction: AgentActionCandidate | null }[]; recentHandoffs: { noteId: string; title: string; authoredAt: number; relatedIds: string[] }[]; alerts: { severity: string; message: string }[]; + diagnostics: Diagnostic[]; graphMeta: { maxTick: number; writerCount: number; tipSha: string } | null; }): string { const lines: string[] = []; @@ -280,6 +286,8 @@ function renderBriefing(briefing: { } } + lines.push(...renderDiagnosticsLines(briefing.diagnostics)); + if (briefing.graphMeta) { lines.push(''); lines.push(`Graph: tick=${briefing.graphMeta.maxTick} writers=${briefing.graphMeta.writerCount} tip=${briefing.graphMeta.tipSha}`); @@ -395,6 +403,7 @@ export function registerAgentCommands(program: Command, ctx: CliContext): void { ctx.jsonOut({ success: true, command: 'briefing', + diagnostics: briefing.diagnostics, data: { ...briefing }, }); return; @@ -480,6 +489,7 @@ export function registerAgentCommands(program: Command, ctx: CliContext): void { ctx.jsonOut({ success: true, command: 'context', + diagnostics: result.diagnostics, data: { id: result.detail.id, type: result.detail.type, @@ -493,6 +503,7 @@ export function registerAgentCommands(program: Command, ctx: CliContext): void { readiness: result.readiness, dependency: result.dependency, recommendedActions: result.recommendedActions, + diagnostics: result.diagnostics, }, }, }); @@ -504,6 +515,7 @@ export function registerAgentCommands(program: Command, ctx: CliContext): void { result.readiness, result.dependency, result.recommendedActions, + result.diagnostics, )); })); diff --git a/src/cli/commands/doctor.ts b/src/cli/commands/doctor.ts index 4fe1216..62c7fcc 100644 --- a/src/cli/commands/doctor.ts +++ b/src/cli/commands/doctor.ts @@ -3,6 +3,7 @@ import type { CliContext } from '../context.js'; import { createErrorHandler } from '../errorHandler.js'; import { DoctorService, type DoctorReport } from '../../domain/services/DoctorService.js'; import { WarpRoadmapAdapter } from '../../infrastructure/adapters/WarpRoadmapAdapter.js'; +import { renderDiagnosticsLines } from '../renderDiagnostics.js'; function renderDoctorReport(report: DoctorReport): string { const lines: string[] = []; @@ -22,25 +23,17 @@ function renderDoctorReport(report: DoctorReport): string { lines.push(''); lines.push('Summary'); - lines.push(` issues=${report.summary.issueCount} errors=${report.summary.errorCount} warnings=${report.summary.warningCount}`); + lines.push(` issues=${report.summary.issueCount} blocking=${report.summary.blockingIssueCount} errors=${report.summary.errorCount} warnings=${report.summary.warningCount}`); lines.push(` danglingEdges=${report.summary.danglingEdges} orphanNodes=${report.summary.orphanNodes}`); lines.push(` readinessGaps=${report.summary.readinessGaps} sovereigntyViolations=${report.summary.sovereigntyViolations} governedCompletionGaps=${report.summary.governedCompletionGaps}`); - if (report.issues.length === 0) { + if (report.diagnostics.length === 0) { lines.push(''); lines.push('No issues found.'); return lines.join('\n'); } - lines.push(''); - lines.push('Issues'); - for (const issue of report.issues) { - const related = issue.relatedIds.length > 0 - ? ` [${issue.relatedIds.join(', ')}]` - : ''; - lines.push(` [${issue.severity.toUpperCase()}] ${issue.code}${issue.nodeId ? ` ${issue.nodeId}` : ''}${related}`); - lines.push(` ${issue.message}`); - } + lines.push(...renderDiagnosticsLines(report.diagnostics)); return lines.join('\n'); } @@ -63,12 +56,14 @@ export function registerDoctorCommands(program: Command, ctx: CliContext): void return ctx.failWithData( `${report.summary.errorCount} blocking graph health issue(s) detected`, report as unknown as Record, + report.diagnostics, ); } ctx.jsonOut({ success: true, command: 'doctor', data: report as unknown as Record, + diagnostics: report.diagnostics, }); return; } diff --git a/src/cli/commands/show.ts b/src/cli/commands/show.ts index e4dbb98..d6bbab3 100644 --- a/src/cli/commands/show.ts +++ b/src/cli/commands/show.ts @@ -1,6 +1,7 @@ import type { Command } from 'commander'; import type { CliContext } from '../context.js'; import { createErrorHandler } from '../errorHandler.js'; +import { renderDiagnosticsLines } from '../renderDiagnostics.js'; import { assertMinLength, assertNodeExists, assertPrefix } from '../validators.js'; import { createPatchSession } from '../../infrastructure/helpers/createPatchSession.js'; import type { ReadinessAssessment } from '../../domain/services/ReadinessService.js'; @@ -11,6 +12,8 @@ import type { QuestDetail, QuestTimelineEntry, } from '../../domain/models/dashboard.js'; +import type { Diagnostic } from '../../domain/models/diagnostics.js'; +import { collectQuestDiagnostics } from '../../domain/services/DiagnosticService.js'; interface NarrativeWriteOptions { on: string; @@ -106,7 +109,11 @@ function renderTimeline(entries: QuestTimelineEntry[]): string[] { return lines; } -function renderQuestDetail(detail: QuestDetail, readiness?: ReadinessAssessment): string { +function renderQuestDetail( + detail: QuestDetail, + readiness?: ReadinessAssessment, + diagnostics: Diagnostic[] = [], +): string { const lines: string[] = []; const { quest } = detail; @@ -171,6 +178,7 @@ function renderQuestDetail(detail: QuestDetail, readiness?: ReadinessAssessment) lines.push(...renderNarrativeLines('Documents', detail.documents)); lines.push(...renderNarrativeLines('Comments', detail.comments)); + lines.push(...renderDiagnosticsLines(diagnostics)); lines.push(...renderTimeline(detail.timeline)); return lines.join('\n'); @@ -231,16 +239,19 @@ export function registerShowCommands(program: Command, ctx: CliContext): void { throw new Error(`[NOT_FOUND] Node ${id} not found in the graph`); } let readiness: ReadinessAssessment | null = null; + let diagnostics: Diagnostic[] = []; if (detail.questDetail) { const { WarpRoadmapAdapter } = await import('../../infrastructure/adapters/WarpRoadmapAdapter.js'); const { ReadinessService } = await import('../../domain/services/ReadinessService.js'); readiness = await new ReadinessService(new WarpRoadmapAdapter(ctx.graphPort)).assess(id, { transition: false }); + diagnostics = collectQuestDiagnostics(detail.questDetail, readiness); } if (ctx.json) { ctx.jsonOut({ success: true, command: 'show', + diagnostics, data: { id: detail.id, type: detail.type, @@ -256,7 +267,9 @@ export function registerShowCommands(program: Command, ctx: CliContext): void { return; } - ctx.print(detail.questDetail ? renderQuestDetail(detail.questDetail, readiness ?? undefined) : renderGenericEntity(detail)); + ctx.print(detail.questDetail + ? renderQuestDetail(detail.questDetail, readiness ?? undefined, diagnostics) + : renderGenericEntity(detail)); })); program diff --git a/src/cli/context.ts b/src/cli/context.ts index 113528e..d9ece2a 100644 --- a/src/cli/context.ts +++ b/src/cli/context.ts @@ -3,6 +3,7 @@ import { createPlainStylePort } from '../infrastructure/adapters/PlainStyleAdapt import type { StylePort } from '../ports/StylePort.js'; import { WarpGraphAdapter } from '../infrastructure/adapters/WarpGraphAdapter.js'; import { resolveIdentity, type ResolvedIdentity } from './identity.js'; +import type { Diagnostic } from '../domain/models/diagnostics.js'; export { DEFAULT_AGENT_ID } from './identity.js'; @@ -10,12 +11,14 @@ export interface JsonEnvelope { success: true; command: string; data: Record; + diagnostics?: Diagnostic[]; } export interface JsonErrorEnvelope { success: false; error: string; data?: Record; + diagnostics?: Diagnostic[]; } export type JsonOutput = JsonEnvelope | JsonErrorEnvelope; @@ -35,7 +38,7 @@ export interface CliContext { * Fail with structured data. The `data` payload is included in the JSON * error envelope; in non-JSON mode only `msg` is printed to stderr. */ - failWithData(msg: string, data: Record): never; + failWithData(msg: string, data: Record, diagnostics?: Diagnostic[]): never; jsonOut(envelope: JsonEnvelope): void; } @@ -61,10 +64,19 @@ export function createCliContext( const jsonMode = opts?.json ?? false; const style = jsonMode ? createPlainStylePort() : createStylePort(); - const emitJsonError = (error: string, data?: Record): void => { - const envelope: JsonErrorEnvelope = data === undefined - ? { success: false, error } - : { success: false, error, data }; + const emitJsonError = ( + error: string, + data?: Record, + diagnostics?: Diagnostic[], + ): void => { + const envelope: JsonErrorEnvelope = { + success: false, + error, + ...(data === undefined ? {} : { data }), + ...(diagnostics === undefined || diagnostics.length === 0 + ? {} + : { diagnostics }), + }; console.log(JSON.stringify(envelope)); }; @@ -98,9 +110,9 @@ export function createCliContext( } process.exit(1); }, - failWithData(msg: string, data: Record): never { + failWithData(msg: string, data: Record, diagnostics?: Diagnostic[]): never { if (jsonMode) { - emitJsonError(msg, data); + emitJsonError(msg, data, diagnostics); } else { console.error(style.styled(style.theme.semantic.error, msg)); } diff --git a/src/cli/renderDiagnostics.ts b/src/cli/renderDiagnostics.ts new file mode 100644 index 0000000..385f866 --- /dev/null +++ b/src/cli/renderDiagnostics.ts @@ -0,0 +1,21 @@ +import type { Diagnostic } from '../domain/models/diagnostics.js'; + +export function renderDiagnosticsLines( + diagnostics: Diagnostic[], + label = 'Diagnostics', +): string[] { + if (diagnostics.length === 0) return []; + + const lines = ['', label]; + for (const diagnostic of diagnostics) { + const target = diagnostic.subjectId ? ` ${diagnostic.subjectId}` : ''; + const related = diagnostic.relatedIds.length > 0 + ? ` [${diagnostic.relatedIds.join(', ')}]` + : ''; + lines.push( + ` - [${diagnostic.severity.toUpperCase()}] ${diagnostic.code}${target}${related}`, + ); + lines.push(` ${diagnostic.message}`); + } + return lines; +} diff --git a/src/domain/models/diagnostics.ts b/src/domain/models/diagnostics.ts new file mode 100644 index 0000000..6f8b226 --- /dev/null +++ b/src/domain/models/diagnostics.ts @@ -0,0 +1,27 @@ +export type DiagnosticSeverity = 'error' | 'warning' | 'notice' | 'suggestion'; + +export type DiagnosticCategory = + | 'structural' + | 'readiness' + | 'governance' + | 'traceability' + | 'workflow'; + +export type DiagnosticSource = + | 'doctor' + | 'readiness' + | 'completion' + | 'settlement' + | 'briefing'; + +export interface Diagnostic { + code: string; + severity: DiagnosticSeverity; + category: DiagnosticCategory; + source: DiagnosticSource; + summary: string; + message: string; + subjectId?: string; + relatedIds: string[]; + blocking: boolean; +} diff --git a/src/domain/services/AgentBriefingService.ts b/src/domain/services/AgentBriefingService.ts index 6f869cf..a125270 100644 --- a/src/domain/services/AgentBriefingService.ts +++ b/src/domain/services/AgentBriefingService.ts @@ -1,11 +1,14 @@ import type { QueryResultV1, AggregateResult } from '@git-stunts/git-warp'; +import type { Diagnostic } from '../models/diagnostics.js'; import type { GraphMeta, GraphSnapshot, QuestNode } from '../models/dashboard.js'; import type { GraphPort } from '../../ports/GraphPort.js'; import type { RoadmapQueryPort } from '../../ports/RoadmapPort.js'; import { createGraphContext } from '../../infrastructure/GraphContext.js'; import { toNeighborEntries } from '../../infrastructure/helpers/isNeighborEntry.js'; +import { summarizeDoctorReport } from './DiagnosticService.js'; import { ReadinessService } from './ReadinessService.js'; import { AgentActionValidator } from './AgentActionService.js'; +import { DoctorService } from './DoctorService.js'; import { determineSubmissionNextStep, isReviewableByAgent, @@ -78,6 +81,7 @@ export interface AgentBriefing { frontier: AgentWorkSummary[]; recentHandoffs: AgentHandoffSummary[]; alerts: AgentBriefingAlert[]; + diagnostics: Diagnostic[]; graphMeta: GraphMeta | null; } @@ -135,13 +139,16 @@ function sourcePriority(source: AgentNextCandidate['source']): number { export class AgentBriefingService { private readonly readiness: ReadinessService; private readonly recommender: AgentRecommender; + private readonly doctor: Pick; constructor( private readonly graphPort: GraphPort, roadmap: RoadmapQueryPort, private readonly agentId: string, + doctor?: Pick, ) { this.readiness = new ReadinessService(roadmap); + this.doctor = doctor ?? new DoctorService(graphPort, roadmap); this.recommender = new AgentRecommender( new AgentActionValidator(graphPort, roadmap, agentId), agentId, @@ -169,7 +176,9 @@ export class AgentBriefingService { const reviewQueue = this.buildReviewQueue(snapshot); const recentHandoffs = await this.buildRecentHandoffs(); - const alerts = this.buildAlerts(assignments, frontier, reviewQueue); + const doctorReport = await this.doctor.run(); + const diagnostics = summarizeDoctorReport(doctorReport); + const alerts = this.buildAlerts(assignments, frontier, reviewQueue, diagnostics); return { identity: { @@ -181,6 +190,7 @@ export class AgentBriefingService { frontier, recentHandoffs, alerts, + diagnostics, graphMeta: snapshot.graphMeta ?? null, }; } @@ -390,9 +400,23 @@ export class AgentBriefingService { assignments: AgentWorkSummary[], frontier: AgentWorkSummary[], reviewQueue: AgentReviewQueueEntry[], + diagnostics: Diagnostic[], ): AgentBriefingAlert[] { const alerts: AgentBriefingAlert[] = []; + for (const diagnostic of diagnostics) { + alerts.push({ + code: diagnostic.code, + severity: diagnostic.severity === 'error' + ? 'critical' + : diagnostic.severity === 'warning' + ? 'warning' + : 'info', + message: diagnostic.message, + relatedIds: diagnostic.relatedIds, + }); + } + const blockedAssignments = assignments.filter((entry) => entry.dependency.blockedBy.length > 0); if (blockedAssignments.length > 0) { alerts.push({ diff --git a/src/domain/services/AgentContextService.ts b/src/domain/services/AgentContextService.ts index 8ea37f0..9229c08 100644 --- a/src/domain/services/AgentContextService.ts +++ b/src/domain/services/AgentContextService.ts @@ -1,9 +1,11 @@ import { isExecutableQuestStatus } from '../entities/Quest.js'; +import type { Diagnostic } from '../models/diagnostics.js'; import type { EntityDetail, GraphSnapshot, QuestNode } from '../models/dashboard.js'; import type { GraphPort } from '../../ports/GraphPort.js'; import type { RoadmapQueryPort } from '../../ports/RoadmapPort.js'; import { createGraphContext } from '../../infrastructure/GraphContext.js'; import { computeFrontier } from './DepAnalysis.js'; +import { collectQuestDiagnostics } from './DiagnosticService.js'; import { ReadinessService, type ReadinessAssessment } from './ReadinessService.js'; import { AgentActionValidator } from './AgentActionService.js'; import { determineSubmissionNextStep } from './AgentSubmissionService.js'; @@ -19,6 +21,7 @@ export interface AgentContextResult { readiness: ReadinessAssessment | null; dependency: AgentDependencyContext | null; recommendedActions: AgentActionCandidate[]; + diagnostics: Diagnostic[]; } export function toAgentQuestRef(quest: QuestNode): AgentQuestRef { @@ -107,6 +110,7 @@ export class AgentContextService { readiness: null, dependency: null, recommendedActions: [], + diagnostics: [], }; } @@ -128,12 +132,14 @@ export class AgentContextService { a.kind.localeCompare(b.kind) ) : questActions; + const diagnostics = collectQuestDiagnostics(detail.questDetail, readiness); return { detail, readiness, dependency, recommendedActions, + diagnostics, }; } diff --git a/src/domain/services/DiagnosticService.ts b/src/domain/services/DiagnosticService.ts new file mode 100644 index 0000000..e6ce6d1 --- /dev/null +++ b/src/domain/services/DiagnosticService.ts @@ -0,0 +1,209 @@ +import type { QuestStatus } from '../entities/Quest.js'; +import type { QuestDetail } from '../models/dashboard.js'; +import type { Diagnostic, DiagnosticCategory } from '../models/diagnostics.js'; +import type { DoctorIssue, DoctorReport } from './DoctorService.js'; +import type { ReadinessAssessment } from './ReadinessService.js'; +import { + assessSettlementGate, + formatSettlementGateFailure, +} from './SettlementGateService.js'; + +function doctorBucketCategory(bucket: DoctorIssue['bucket']): DiagnosticCategory { + switch (bucket) { + case 'dangling-edge': + case 'orphan-node': + return 'structural'; + case 'readiness-gap': + return 'readiness'; + case 'sovereignty-violation': + case 'governed-completion-gap': + return 'governance'; + default: + return 'workflow'; + } +} + +function readinessRelevant(status: QuestStatus | undefined): boolean { + return ( + status === 'PLANNED' || + status === 'READY' || + status === 'IN_PROGRESS' || + status === 'BLOCKED' || + status === 'DONE' + ); +} + +function dedupeDiagnostics(diagnostics: Diagnostic[]): Diagnostic[] { + const seen = new Set(); + return diagnostics.filter((diagnostic) => { + const key = [ + diagnostic.code, + diagnostic.subjectId ?? '', + diagnostic.severity, + ...diagnostic.relatedIds.slice().sort(), + ].join('|'); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +export function doctorIssueToDiagnostic(issue: DoctorIssue): Diagnostic { + return { + code: issue.code, + severity: issue.severity, + category: doctorBucketCategory(issue.bucket), + source: 'doctor', + summary: issue.nodeId + ? `${issue.nodeId} triggered ${issue.code}` + : issue.code, + message: issue.message, + subjectId: issue.nodeId, + relatedIds: issue.relatedIds, + blocking: issue.severity === 'error', + }; +} + +export function summarizeDoctorReport(report: DoctorReport): Diagnostic[] { + const diagnostics: Diagnostic[] = []; + + if (report.summary.errorCount > 0) { + diagnostics.push({ + code: 'graph-health-blocking', + severity: 'error', + category: 'structural', + source: 'briefing', + summary: `${report.summary.errorCount} blocking graph health issue(s) need attention.`, + message: `${report.summary.errorCount} blocking graph health issue(s) need attention before XYPH can be treated as fully trustworthy.`, + relatedIds: [], + blocking: true, + }); + } + + if (report.summary.readinessGaps > 0) { + diagnostics.push({ + code: 'graph-health-readiness-gaps', + severity: 'warning', + category: 'readiness', + source: 'briefing', + summary: `${report.summary.readinessGaps} quest(s) fail the readiness contract.`, + message: `${report.summary.readinessGaps} quest(s) still fail the readiness contract, so executable work and planning truth are drifting apart.`, + relatedIds: [], + blocking: false, + }); + } + + if (report.summary.governedCompletionGaps > 0) { + diagnostics.push({ + code: 'graph-health-governed-gaps', + severity: 'warning', + category: 'governance', + source: 'briefing', + summary: `${report.summary.governedCompletionGaps} governed quest(s) are incomplete or untracked.`, + message: `${report.summary.governedCompletionGaps} governed quest(s) are incomplete or untracked, so governance claims are ahead of graph reality.`, + relatedIds: [], + blocking: false, + }); + } + + return diagnostics; +} + +export function collectQuestDiagnostics( + detail: QuestDetail, + readiness: ReadinessAssessment | null, +): Diagnostic[] { + const diagnostics: Diagnostic[] = []; + const quest = detail.quest; + + if (readiness && readinessRelevant(readiness.status)) { + for (const unmet of readiness.unmet) { + diagnostics.push({ + code: `readiness-${unmet.code}`, + severity: 'warning', + category: 'readiness', + source: 'readiness', + summary: unmet.message, + message: unmet.message, + subjectId: unmet.nodeId ?? quest.id, + relatedIds: unmet.nodeId ? [unmet.nodeId] : [], + blocking: true, + }); + } + } + + const computed = quest.computedCompletion; + const appliedPolicy = detail.policies.find((policy) => policy.id === computed?.policyId) + ?? detail.policies[0]; + + if (computed?.discrepancy) { + diagnostics.push({ + code: `completion-${computed.discrepancy.toLowerCase()}`, + severity: 'warning', + category: 'governance', + source: 'completion', + summary: `${quest.id} manual status disagrees with computed completion.`, + message: `${quest.id} manual status disagrees with computed completion (${computed.discrepancy}).`, + subjectId: quest.id, + relatedIds: [], + blocking: false, + }); + } + + if (appliedPolicy && !computed) { + diagnostics.push({ + code: 'governance-missing-computed-completion', + severity: 'warning', + category: 'governance', + source: 'completion', + summary: `${quest.id} is governed but has no computed completion state.`, + message: `${quest.id} is governed by ${appliedPolicy.id} but has no computed completion state yet.`, + subjectId: quest.id, + relatedIds: [appliedPolicy.id], + blocking: true, + }); + } else if (appliedPolicy && computed && !computed.tracked) { + diagnostics.push({ + code: 'governance-untracked-work', + severity: 'warning', + category: 'traceability', + source: 'completion', + summary: `${quest.id} is governed but untracked.`, + message: `${quest.id} is governed by ${appliedPolicy.id} but still lacks enough traceability structure to compute completion honestly.`, + subjectId: quest.id, + relatedIds: [appliedPolicy.id], + blocking: true, + }); + } else if (appliedPolicy && computed && !computed.complete) { + diagnostics.push({ + code: `governance-incomplete-${computed.verdict.toLowerCase()}`, + severity: 'warning', + category: 'traceability', + source: 'completion', + summary: `${quest.id} is governed and currently ${computed.verdict}.`, + message: `${quest.id} is governed by ${appliedPolicy.id} and currently computes as ${computed.verdict}.`, + subjectId: quest.id, + relatedIds: [appliedPolicy.id], + blocking: true, + }); + } + + if (detail.submission) { + const settlement = assessSettlementGate(detail, 'seal'); + if (!settlement.allowed) { + diagnostics.push({ + code: `settlement-${settlement.code ?? 'blocked'}`, + severity: 'warning', + category: 'workflow', + source: 'settlement', + summary: `${quest.id} cannot settle yet.`, + message: formatSettlementGateFailure(settlement), + subjectId: quest.id, + relatedIds: settlement.submissionId ? [settlement.submissionId] : [], + blocking: true, + }); + } + } + + return dedupeDiagnostics(diagnostics); +} diff --git a/src/domain/services/DoctorService.ts b/src/domain/services/DoctorService.ts index 8cf53a9..631c4c5 100644 --- a/src/domain/services/DoctorService.ts +++ b/src/domain/services/DoctorService.ts @@ -2,10 +2,12 @@ import type WarpGraph from '@git-stunts/git-warp'; import type { QueryResultV1, AggregateResult } from '@git-stunts/git-warp'; import type { GraphPort } from '../../ports/GraphPort.js'; import type { RoadmapQueryPort } from '../../ports/RoadmapPort.js'; +import type { Diagnostic } from '../models/diagnostics.js'; import type { GraphMeta, GraphSnapshot } from '../models/dashboard.js'; import { createGraphContext } from '../../infrastructure/GraphContext.js'; import { toNeighborEntries, type NeighborEntry } from '../../infrastructure/helpers/isNeighborEntry.js'; import { ReadinessService } from './ReadinessService.js'; +import { doctorIssueToDiagnostic } from './DiagnosticService.js'; import { SovereigntyService, SOVEREIGNTY_AUDIT_STATUSES, @@ -50,6 +52,7 @@ export interface DoctorIssue { export interface DoctorSummary { issueCount: number; + blockingIssueCount: number; errorCount: number; warningCount: number; danglingEdges: number; @@ -89,6 +92,7 @@ export interface DoctorReport { counts: DoctorCounts; summary: DoctorSummary; issues: DoctorIssue[]; + diagnostics: Diagnostic[]; } function extractNodes(result: QueryResultV1 | AggregateResult): QNode[] { @@ -239,6 +243,7 @@ export class DoctorService { }; const summary: DoctorSummary = { issueCount: issues.length, + blockingIssueCount: errorCount, errorCount, warningCount, danglingEdges: issues.filter((issue) => issue.bucket === 'dangling-edge').length, @@ -247,6 +252,7 @@ export class DoctorService { sovereigntyViolations: issues.filter((issue) => issue.bucket === 'sovereignty-violation').length, governedCompletionGaps: issues.filter((issue) => issue.bucket === 'governed-completion-gap').length, }; + const diagnostics = issues.map(doctorIssueToDiagnostic); const status: DoctorStatus = errorCount > 0 ? 'error' @@ -264,6 +270,7 @@ export class DoctorService { counts, summary, issues, + diagnostics, }; } diff --git a/test/unit/AgentBriefingService.test.ts b/test/unit/AgentBriefingService.test.ts index c4224dc..dbd4191 100644 --- a/test/unit/AgentBriefingService.test.ts +++ b/test/unit/AgentBriefingService.test.ts @@ -56,6 +56,60 @@ function makeRoadmap( }; } +function makeDoctor( + overrides?: Partial<{ + diagnostics: unknown[]; + summary: Record; + blocking: boolean; + healthy: boolean; + status: string; + }>, +) { + return { + run: vi.fn().mockResolvedValue({ + status: overrides?.status ?? 'ok', + healthy: overrides?.healthy ?? true, + blocking: overrides?.blocking ?? false, + asOf: 1, + graphMeta: null, + auditedStatuses: ['PLANNED', 'READY'], + counts: { + campaigns: 0, + quests: 0, + intents: 0, + scrolls: 0, + approvals: 0, + submissions: 0, + patchsets: 0, + reviews: 0, + decisions: 0, + stories: 0, + requirements: 0, + criteria: 0, + evidence: 0, + policies: 0, + suggestions: 0, + documents: 0, + comments: 0, + }, + summary: { + issueCount: 0, + blockingIssueCount: 0, + errorCount: 0, + warningCount: 0, + danglingEdges: 0, + orphanNodes: 0, + readinessGaps: 0, + sovereigntyViolations: 0, + governedCompletionGaps: 0, + ...(overrides?.summary ?? {}), + }, + issues: [], + diagnostics: overrides?.diagnostics ?? [], + }), + }; +} + describe('AgentBriefingService', () => { beforeEach(() => { vi.clearAllMocks(); @@ -179,6 +233,7 @@ describe('AgentBriefingService', () => { }, ), 'agent.hal', + makeDoctor(), ); const briefing = await service.buildBriefing(); @@ -213,6 +268,7 @@ describe('AgentBriefingService', () => { }, ]); expect(briefing.graphMeta?.tipSha).toBe('abc1234'); + expect(briefing.diagnostics).toEqual([]); expect(briefing.alerts.map((alert) => alert.code)).toContain('review-queue'); }); @@ -302,6 +358,7 @@ describe('AgentBriefingService', () => { }, ), 'agent.hal', + makeDoctor(), ); const candidates = await service.next(5); @@ -397,6 +454,7 @@ describe('AgentBriefingService', () => { }), ]), 'agent.hal', + makeDoctor(), ); const candidates = await service.next(5); @@ -467,6 +525,7 @@ describe('AgentBriefingService', () => { }), ]), 'agent.hal', + makeDoctor(), ); const briefing = await service.buildBriefing(); diff --git a/test/unit/AgentCommands.test.ts b/test/unit/AgentCommands.test.ts index 072f011..286c0b6 100644 --- a/test/unit/AgentCommands.test.ts +++ b/test/unit/AgentCommands.test.ts @@ -89,6 +89,7 @@ describe('agent act command', () => { frontier: [], recentHandoffs: [], alerts: [], + diagnostics: [], graphMeta: { maxTick: 42, myTick: 7, @@ -107,6 +108,7 @@ describe('agent act command', () => { expect(ctx.jsonOut).toHaveBeenCalledWith({ success: true, command: 'briefing', + diagnostics: [], data: { identity: { agentId: 'agent.hal', @@ -117,6 +119,7 @@ describe('agent act command', () => { frontier: [], recentHandoffs: [], alerts: [], + diagnostics: [], graphMeta: { maxTick: 42, myTick: 7, @@ -242,6 +245,7 @@ describe('agent act command', () => { sideEffects: ['status -> IN_PROGRESS'], validationCode: null, }], + diagnostics: [], }); const ctx = makeCtx(); @@ -254,6 +258,7 @@ describe('agent act command', () => { expect(ctx.jsonOut).toHaveBeenCalledWith({ success: true, command: 'context', + diagnostics: [], data: { id: 'task:CTX-001', type: 'task', @@ -312,6 +317,7 @@ describe('agent act command', () => { sideEffects: ['status -> IN_PROGRESS'], validationCode: null, }], + diagnostics: [], }, }, }); diff --git a/test/unit/CliJsonOutput.test.ts b/test/unit/CliJsonOutput.test.ts index 979bd7a..9178047 100644 --- a/test/unit/CliJsonOutput.test.ts +++ b/test/unit/CliJsonOutput.test.ts @@ -155,4 +155,32 @@ describe('CliContext JSON mode', () => { expect(Array.isArray(parsed.data.violations)).toBe(true); expect(parsed.data.violations).toHaveLength(2); }); + + it('failWithData includes diagnostics when provided', () => { + const ctx = createCliContext('/tmp', 'test-graph', { json: true, identity: TEST_IDENTITY }); + + ctx.failWithData( + 'graph health degraded', + { status: 'warn' }, + [{ + code: 'graph-health-readiness-gaps', + severity: 'warning', + category: 'readiness', + source: 'briefing', + summary: '2 quest(s) fail the readiness contract.', + message: '2 quest(s) fail the readiness contract.', + relatedIds: [], + blocking: false, + }], + ); + + const output = logSpy.mock.calls[0]?.[0] as string; + const parsed = JSON.parse(output); + expect(parsed.diagnostics).toEqual([ + expect.objectContaining({ + code: 'graph-health-readiness-gaps', + severity: 'warning', + }), + ]); + }); }); diff --git a/test/unit/DiagnosticService.test.ts b/test/unit/DiagnosticService.test.ts new file mode 100644 index 0000000..fb46092 --- /dev/null +++ b/test/unit/DiagnosticService.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from 'vitest'; +import { + collectQuestDiagnostics, + summarizeDoctorReport, +} from '../../src/domain/services/DiagnosticService.js'; +import type { DoctorReport } from '../../src/domain/services/DoctorService.js'; +import type { QuestDetail } from '../../src/domain/models/dashboard.js'; +import type { ReadinessAssessment } from '../../src/domain/services/ReadinessService.js'; + +function makeDoctorReport(): DoctorReport { + return { + status: 'error', + healthy: false, + blocking: true, + asOf: 1, + graphMeta: null, + auditedStatuses: ['PLANNED', 'READY'], + counts: { + campaigns: 1, + quests: 2, + intents: 1, + scrolls: 0, + approvals: 0, + submissions: 0, + patchsets: 0, + reviews: 0, + decisions: 0, + stories: 0, + requirements: 0, + criteria: 0, + evidence: 0, + policies: 1, + suggestions: 0, + documents: 0, + comments: 0, + }, + summary: { + issueCount: 4, + blockingIssueCount: 1, + errorCount: 1, + warningCount: 3, + danglingEdges: 1, + orphanNodes: 0, + readinessGaps: 2, + sovereigntyViolations: 0, + governedCompletionGaps: 1, + }, + issues: [], + diagnostics: [], + }; +} + +function makeQuestDetail(): QuestDetail { + return { + id: 'task:TRACE-001', + quest: { + id: 'task:TRACE-001', + title: 'Trace quest', + status: 'PLANNED', + hours: 2, + taskKind: 'delivery', + description: 'Needs a complete packet.', + computedCompletion: { + tracked: false, + complete: false, + verdict: 'UNTRACKED', + requirementCount: 0, + criterionCount: 0, + coverageRatio: 0, + satisfiedCount: 0, + failingCriterionIds: [], + linkedOnlyCriterionIds: [], + missingCriterionIds: [], + policyId: 'policy:TRACE', + }, + }, + submission: { + id: 'submission:TRACE-001', + questId: 'task:TRACE-001', + status: 'OPEN', + submittedBy: 'agent.builder', + submittedAt: 1, + headsCount: 1, + approvalCount: 0, + tipPatchsetId: 'patchset:TRACE-001', + }, + reviews: [], + decisions: [], + stories: [], + requirements: [], + criteria: [], + evidence: [], + policies: [{ + id: 'policy:TRACE', + campaignId: 'campaign:TRACE', + coverageThreshold: 1, + requireAllCriteria: true, + requireEvidence: true, + allowManualSeal: false, + }], + documents: [], + comments: [], + timeline: [], + }; +} + +describe('DiagnosticService', () => { + it('summarizes doctor health into briefing-friendly diagnostics', () => { + expect(summarizeDoctorReport(makeDoctorReport())).toEqual([ + expect.objectContaining({ + code: 'graph-health-blocking', + severity: 'error', + }), + expect.objectContaining({ + code: 'graph-health-readiness-gaps', + severity: 'warning', + }), + expect.objectContaining({ + code: 'graph-health-governed-gaps', + severity: 'warning', + }), + ]); + }); + + it('collects readiness, governance, and settlement diagnostics for a quest', () => { + const readiness: ReadinessAssessment = { + valid: false, + questId: 'task:TRACE-001', + status: 'PLANNED', + taskKind: 'delivery', + unmet: [{ + code: 'missing-criterion', + field: 'traceability', + message: 'req:TRACE-001 needs at least one has-criterion edge before task:TRACE-001 can become READY', + nodeId: 'req:TRACE-001', + }], + }; + + const diagnostics = collectQuestDiagnostics(makeQuestDetail(), readiness); + + expect(diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ + code: 'readiness-missing-criterion', + category: 'readiness', + blocking: true, + }), + expect.objectContaining({ + code: 'governance-untracked-work', + category: 'traceability', + blocking: true, + }), + expect.objectContaining({ + code: 'settlement-governed-work-untracked', + category: 'workflow', + blocking: true, + }), + ])); + }); +}); diff --git a/test/unit/DoctorCommands.test.ts b/test/unit/DoctorCommands.test.ts index 2b95d65..743964e 100644 --- a/test/unit/DoctorCommands.test.ts +++ b/test/unit/DoctorCommands.test.ts @@ -92,6 +92,7 @@ describe('doctor command', () => { }, summary: { issueCount: 1, + blockingIssueCount: 0, errorCount: 0, warningCount: 1, danglingEdges: 0, @@ -110,6 +111,19 @@ describe('doctor command', () => { relatedIds: [], }, ], + diagnostics: [ + { + code: 'orphan-comment', + severity: 'warning', + category: 'structural', + source: 'doctor', + summary: 'comment:1 triggered orphan-comment', + message: 'comment:1 is not attached', + subjectId: 'comment:1', + relatedIds: [], + blocking: false, + }, + ], }; runDoctor.mockResolvedValueOnce(report); @@ -124,6 +138,7 @@ describe('doctor command', () => { success: true, command: 'doctor', data: report, + diagnostics: report.diagnostics, }); expect(ctx.failWithData).not.toHaveBeenCalled(); }); @@ -157,6 +172,7 @@ describe('doctor command', () => { }, summary: { issueCount: 2, + blockingIssueCount: 2, errorCount: 2, warningCount: 0, danglingEdges: 1, @@ -166,6 +182,19 @@ describe('doctor command', () => { governedCompletionGaps: 0, }, issues: [], + diagnostics: [ + { + code: 'dangling-outgoing-depends-on', + severity: 'error', + category: 'structural', + source: 'doctor', + summary: 'task:BAD triggered dangling-outgoing-depends-on', + message: 'task:BAD has an outgoing depends-on edge to missing node task:NOPE', + subjectId: 'task:BAD', + relatedIds: ['task:NOPE'], + blocking: true, + }, + ], }; runDoctor.mockResolvedValueOnce(report); @@ -177,6 +206,7 @@ describe('doctor command', () => { expect(ctx.failWithData).toHaveBeenCalledWith( '2 blocking graph health issue(s) detected', report as unknown as Record, + report.diagnostics, ); expect(ctx.jsonOut).not.toHaveBeenCalled(); }); diff --git a/test/unit/DoctorService.test.ts b/test/unit/DoctorService.test.ts index 4ce6fdf..e5be59f 100644 --- a/test/unit/DoctorService.test.ts +++ b/test/unit/DoctorService.test.ts @@ -229,11 +229,24 @@ describe('DoctorService', () => { expect(report.counts.patchsets).toBe(1); expect(report.counts.documents).toBe(1); expect(report.counts.comments).toBe(1); + expect(report.summary.blockingIssueCount).toBe(report.summary.errorCount); expect(report.summary.danglingEdges).toBe(2); expect(report.summary.orphanNodes).toBeGreaterThanOrEqual(8); expect(report.summary.readinessGaps).toBe(1); expect(report.summary.sovereigntyViolations).toBe(1); expect(report.summary.governedCompletionGaps).toBe(1); + expect(report.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ + code: 'dangling-outgoing-depends-on', + severity: 'error', + category: 'structural', + }), + expect.objectContaining({ + code: 'orphan-comment', + severity: 'warning', + category: 'structural', + }), + ])); expect(report.issues).toEqual(expect.arrayContaining([ expect.objectContaining({ bucket: 'dangling-edge', diff --git a/test/unit/ShowCommands.test.ts b/test/unit/ShowCommands.test.ts index e99025e..dc18a6e 100644 --- a/test/unit/ShowCommands.test.ts +++ b/test/unit/ShowCommands.test.ts @@ -202,6 +202,7 @@ describe('show and narrative commands', () => { expect(ctx.jsonOut).toHaveBeenCalledWith({ success: true, command: 'show', + diagnostics: [], data: { id: 'task:Q-001', type: 'task', From 5b136419e6817c641d01fc1f578e52538e07de9f Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 14 Mar 2026 01:47:22 -0700 Subject: [PATCH 21/22] Thread diagnostics through status and preflights --- src/cli/commands/agent.ts | 8 +- src/cli/commands/artifact.ts | 2 + src/cli/commands/dashboard.ts | 46 ++++++--- src/cli/commands/intake.ts | 4 +- src/cli/commands/submission.ts | 2 + src/domain/services/AgentBriefingService.ts | 13 ++- src/domain/services/DiagnosticService.ts | 74 +++++++++------ test/unit/AgentBriefingService.test.ts | 18 ++-- test/unit/AgentCommands.test.ts | 42 ++++---- test/unit/DashboardTraceCommand.test.ts | 81 +++++++++++++++- test/unit/IntakeCommands.test.ts | 100 ++++++++++++++++++++ test/unit/SignedSettlementCommands.test.ts | 24 ++++- 12 files changed, 337 insertions(+), 77 deletions(-) create mode 100644 test/unit/IntakeCommands.test.ts diff --git a/src/cli/commands/agent.ts b/src/cli/commands/agent.ts index d7bdc81..6814d55 100644 --- a/src/cli/commands/agent.ts +++ b/src/cli/commands/agent.ts @@ -427,20 +427,22 @@ export function registerAgentCommands(program: Command, ctx: CliContext): void { new WarpRoadmapAdapter(ctx.graphPort), ctx.agentId, ); - const candidates = await service.next(limit); + const result = await service.next(limit); if (ctx.json) { ctx.jsonOut({ success: true, command: 'next', + diagnostics: result.diagnostics, data: { - candidates, + candidates: result.candidates, }, }); return; } - ctx.print(renderNext(candidates)); + const lines = [renderNext(result.candidates), ...renderDiagnosticsLines(result.diagnostics)]; + ctx.print(lines.join('\n')); })); program diff --git a/src/cli/commands/artifact.ts b/src/cli/commands/artifact.ts index 3839e73..a722f50 100644 --- a/src/cli/commands/artifact.ts +++ b/src/cli/commands/artifact.ts @@ -13,6 +13,7 @@ import { formatSettlementGateFailure, settlementGateFailureData, } from '../../domain/services/SettlementGateService.js'; +import { settlementAssessmentToDiagnostics } from '../../domain/services/DiagnosticService.js'; export { allowUnsignedScrollsForSettlement, formatMissingSettlementKeyMessage, @@ -44,6 +45,7 @@ export function registerArtifactCommands(program: Command, ctx: CliContext): voi return ctx.failWithData( formatSettlementGateFailure(assessment), settlementGateFailureData(assessment), + settlementAssessmentToDiagnostics(assessment), ); } diff --git a/src/cli/commands/dashboard.ts b/src/cli/commands/dashboard.ts index 3f452e5..6ae20e0 100644 --- a/src/cli/commands/dashboard.ts +++ b/src/cli/commands/dashboard.ts @@ -1,8 +1,12 @@ import type { Command } from 'commander'; import type { CliContext } from '../context.js'; import { createErrorHandler } from '../errorHandler.js'; +import { renderDiagnosticsLines } from '../renderDiagnostics.js'; import { assertPrefixOneOf, assertNodeExists } from '../validators.js'; import { isExecutableQuestStatus } from '../../domain/entities/Quest.js'; +import { summarizeDoctorReport } from '../../domain/services/DiagnosticService.js'; +import { DoctorService } from '../../domain/services/DoctorService.js'; +import { WarpRoadmapAdapter } from '../../infrastructure/adapters/WarpRoadmapAdapter.js'; export function registerDashboardCommands(program: Command, ctx: CliContext): void { const withErrorHandler = createErrorHandler(ctx); @@ -89,6 +93,19 @@ export function registerDashboardCommands(program: Command, ctx: CliContext): vo const graphCtx = createGraphContext(ctx.graphPort); const raw = await graphCtx.fetchSnapshot(); const snapshot = graphCtx.filterSnapshot(raw, { includeGraveyard: opts.includeGraveyard ?? false }); + const doctorReport = await new DoctorService( + ctx.graphPort, + new WarpRoadmapAdapter(ctx.graphPort), + ).run(); + const diagnostics = summarizeDoctorReport(doctorReport); + const health = { + status: doctorReport.status, + blocking: doctorReport.blocking, + summary: doctorReport.summary, + }; + const printWithDiagnostics = (body: string): void => { + ctx.print([body, ...renderDiagnosticsLines(diagnostics)].join('\n')); + }; switch (view) { case 'deps': { @@ -133,9 +150,10 @@ export function registerDashboardCommands(program: Command, ctx: CliContext): vo const milestonesObj: Record = {}; for (const [k, v] of milestones) milestonesObj[k] = v; ctx.jsonOut({ - success: true, command: 'status', + success: true, command: 'status', diagnostics, data: { view: 'deps', + health, frontier: frontierResult.frontier, blockedBy: blockedByObj, executionOrder: sorted, @@ -153,7 +171,7 @@ export function registerDashboardCommands(program: Command, ctx: CliContext): vo } const { renderDeps } = await import('../../tui/render-status.js'); - ctx.print(renderDeps({ + printWithDiagnostics(renderDeps({ frontier: frontierResult.frontier, blockedBy: frontierResult.blockedBy, executionOrder: sorted, @@ -234,9 +252,10 @@ export function registerDashboardCommands(program: Command, ctx: CliContext): vo if (ctx.json) { ctx.jsonOut({ - success: true, command: 'status', + success: true, command: 'status', diagnostics, data: { view: 'trace', + health, stories: snapshot.stories, requirements: snapshot.requirements, criteria: snapshot.criteria, @@ -273,7 +292,7 @@ export function registerDashboardCommands(program: Command, ctx: CliContext): vo } const { renderTrace } = await import('../../tui/render-status.js'); - ctx.print(renderTrace({ + printWithDiagnostics(renderTrace({ stories: snapshot.stories, requirements: snapshot.requirements, criteria: snapshot.criteria, @@ -294,9 +313,10 @@ export function registerDashboardCommands(program: Command, ctx: CliContext): vo case 'suggestions': { if (ctx.json) { ctx.jsonOut({ - success: true, command: 'status', + success: true, command: 'status', diagnostics, data: { view: 'suggestions', + health, suggestions: snapshot.suggestions, summary: { total: snapshot.suggestions.length, @@ -310,15 +330,15 @@ export function registerDashboardCommands(program: Command, ctx: CliContext): vo } const { renderSuggestions } = await import('../../tui/render-status.js'); - ctx.print(renderSuggestions({ suggestions: snapshot.suggestions }, ctx.style)); + printWithDiagnostics(renderSuggestions({ suggestions: snapshot.suggestions }, ctx.style)); break; } default: { if (ctx.json) { ctx.jsonOut({ - success: true, command: 'status', - data: { ...snapshot, view }, + success: true, command: 'status', diagnostics, + data: { ...snapshot, view, health }, }); return; } @@ -326,11 +346,11 @@ export function registerDashboardCommands(program: Command, ctx: CliContext): vo const { renderRoadmap, renderLineage, renderAll, renderInbox, renderSubmissions } = await import('../../tui/render-status.js'); switch (view) { - case 'lineage': ctx.print(renderLineage(snapshot, ctx.style)); break; - case 'all': ctx.print(renderAll(snapshot, ctx.style)); break; - case 'inbox': ctx.print(renderInbox(snapshot, ctx.style)); break; - case 'submissions': ctx.print(renderSubmissions(snapshot, ctx.style)); break; - default: ctx.print(renderRoadmap(snapshot, ctx.style)); break; + case 'lineage': printWithDiagnostics(renderLineage(snapshot, ctx.style)); break; + case 'all': printWithDiagnostics(renderAll(snapshot, ctx.style)); break; + case 'inbox': printWithDiagnostics(renderInbox(snapshot, ctx.style)); break; + case 'submissions': printWithDiagnostics(renderSubmissions(snapshot, ctx.style)); break; + default: printWithDiagnostics(renderRoadmap(snapshot, ctx.style)); break; } } } diff --git a/src/cli/commands/intake.ts b/src/cli/commands/intake.ts index c14eaee..666e590 100644 --- a/src/cli/commands/intake.ts +++ b/src/cli/commands/intake.ts @@ -3,6 +3,7 @@ import type { CliContext } from '../context.js'; import { createErrorHandler } from '../errorHandler.js'; import { assertPrefix, assertMinLength, assertPrefixOneOf, parseHours } from '../validators.js'; import { VALID_TASK_KINDS, type QuestKind } from '../../domain/entities/Quest.js'; +import { collectReadinessDiagnostics } from '../../domain/services/DiagnosticService.js'; function resolveTaskKind(raw: string | undefined): QuestKind { const taskKind = raw ?? 'delivery'; @@ -119,6 +120,7 @@ export function registerIntakeCommands(program: Command, ctx: CliContext): void const readiness = new ReadinessService(new WarpRoadmapAdapter(ctx.graphPort)); const assessment = await readiness.assess(id); if (!assessment.valid) { + const diagnostics = collectReadinessDiagnostics(assessment, id); if (ctx.json) { ctx.failWithData(`[NOT_READY] ${id} does not satisfy readiness requirements`, { valid: false, @@ -128,7 +130,7 @@ export function registerIntakeCommands(program: Command, ctx: CliContext): void intentId: assessment.intentId ?? null, campaignId: assessment.campaignId ?? null, unmet: assessment.unmet, - }); + }, diagnostics); } ctx.fail(`[NOT_READY] ${assessment.unmet.map((item) => item.message).join('\n - ')}`); } diff --git a/src/cli/commands/submission.ts b/src/cli/commands/submission.ts index 48ada5a..d421f19 100644 --- a/src/cli/commands/submission.ts +++ b/src/cli/commands/submission.ts @@ -14,6 +14,7 @@ import { formatSettlementGateFailure, settlementGateFailureData, } from '../../domain/services/SettlementGateService.js'; +import { settlementAssessmentToDiagnostics } from '../../domain/services/DiagnosticService.js'; export function registerSubmissionCommands(program: Command, ctx: CliContext): void { const withErrorHandler = createErrorHandler(ctx); @@ -218,6 +219,7 @@ export function registerSubmissionCommands(program: Command, ctx: CliContext): v submissionId, ...settlementGateFailureData(assessment), }, + settlementAssessmentToDiagnostics(assessment), ); } } diff --git a/src/domain/services/AgentBriefingService.ts b/src/domain/services/AgentBriefingService.ts index a125270..6e1dcd1 100644 --- a/src/domain/services/AgentBriefingService.ts +++ b/src/domain/services/AgentBriefingService.ts @@ -91,6 +91,11 @@ export interface AgentNextCandidate extends AgentActionCandidate { source: 'assignment' | 'frontier' | 'planning' | 'submission'; } +export interface AgentNextResult { + candidates: AgentNextCandidate[]; + diagnostics: Diagnostic[]; +} + function determineSource( quest: QuestNode, dependency: AgentDependencyContext, @@ -195,7 +200,7 @@ export class AgentBriefingService { }; } - public async next(limit = 5): Promise { + public async next(limit = 5): Promise { const snapshot = await this.fetchSnapshot(); const candidates: AgentNextCandidate[] = []; @@ -226,7 +231,11 @@ export class AgentBriefingService { a.targetId.localeCompare(b.targetId) ); - return candidates.slice(0, limit); + const doctorReport = await this.doctor.run(); + return { + candidates: candidates.slice(0, limit), + diagnostics: summarizeDoctorReport(doctorReport), + }; } private async fetchSnapshot(): Promise { diff --git a/src/domain/services/DiagnosticService.ts b/src/domain/services/DiagnosticService.ts index e6ce6d1..2536e7c 100644 --- a/src/domain/services/DiagnosticService.ts +++ b/src/domain/services/DiagnosticService.ts @@ -6,6 +6,7 @@ import type { ReadinessAssessment } from './ReadinessService.js'; import { assessSettlementGate, formatSettlementGateFailure, + type SettlementGateAssessment, } from './SettlementGateService.js'; function doctorBucketCategory(bucket: DoctorIssue['bucket']): DiagnosticCategory { @@ -109,6 +110,49 @@ export function summarizeDoctorReport(report: DoctorReport): Diagnostic[] { return diagnostics; } +export function collectReadinessDiagnostics( + assessment: ReadinessAssessment | null, + questId?: string, +): Diagnostic[] { + if (!assessment || !readinessRelevant(assessment.status)) return []; + + const diagnostics: Diagnostic[] = []; + + for (const unmet of assessment.unmet) { + diagnostics.push({ + code: `readiness-${unmet.code}`, + severity: 'warning', + category: 'readiness', + source: 'readiness', + summary: unmet.message, + message: unmet.message, + subjectId: unmet.nodeId ?? questId ?? assessment.questId, + relatedIds: unmet.nodeId ? [unmet.nodeId] : [], + blocking: true, + }); + } + + return diagnostics; +} + +export function settlementAssessmentToDiagnostics( + assessment: SettlementGateAssessment, +): Diagnostic[] { + if (assessment.allowed) return []; + + return [{ + code: `settlement-${assessment.code ?? 'blocked'}`, + severity: 'warning', + category: 'workflow', + source: 'settlement', + summary: `${assessment.questId} cannot ${assessment.action} yet.`, + message: formatSettlementGateFailure(assessment), + subjectId: assessment.questId, + relatedIds: assessment.submissionId ? [assessment.submissionId] : [], + blocking: true, + }]; +} + export function collectQuestDiagnostics( detail: QuestDetail, readiness: ReadinessAssessment | null, @@ -116,21 +160,7 @@ export function collectQuestDiagnostics( const diagnostics: Diagnostic[] = []; const quest = detail.quest; - if (readiness && readinessRelevant(readiness.status)) { - for (const unmet of readiness.unmet) { - diagnostics.push({ - code: `readiness-${unmet.code}`, - severity: 'warning', - category: 'readiness', - source: 'readiness', - summary: unmet.message, - message: unmet.message, - subjectId: unmet.nodeId ?? quest.id, - relatedIds: unmet.nodeId ? [unmet.nodeId] : [], - blocking: true, - }); - } - } + diagnostics.push(...collectReadinessDiagnostics(readiness, quest.id)); const computed = quest.computedCompletion; const appliedPolicy = detail.policies.find((policy) => policy.id === computed?.policyId) @@ -190,19 +220,7 @@ export function collectQuestDiagnostics( if (detail.submission) { const settlement = assessSettlementGate(detail, 'seal'); - if (!settlement.allowed) { - diagnostics.push({ - code: `settlement-${settlement.code ?? 'blocked'}`, - severity: 'warning', - category: 'workflow', - source: 'settlement', - summary: `${quest.id} cannot settle yet.`, - message: formatSettlementGateFailure(settlement), - subjectId: quest.id, - relatedIds: settlement.submissionId ? [settlement.submissionId] : [], - blocking: true, - }); - } + diagnostics.push(...settlementAssessmentToDiagnostics(settlement)); } return dedupeDiagnostics(diagnostics); diff --git a/test/unit/AgentBriefingService.test.ts b/test/unit/AgentBriefingService.test.ts index dbd4191..26ad34e 100644 --- a/test/unit/AgentBriefingService.test.ts +++ b/test/unit/AgentBriefingService.test.ts @@ -361,15 +361,16 @@ describe('AgentBriefingService', () => { makeDoctor(), ); - const candidates = await service.next(5); + const result = await service.next(5); - expect(candidates).toHaveLength(2); - expect(candidates[0]).toMatchObject({ + expect(result.diagnostics).toEqual([]); + expect(result.candidates).toHaveLength(2); + expect(result.candidates[0]).toMatchObject({ kind: 'claim', targetId: 'task:AGT-READY', source: 'assignment', }); - expect(candidates[1]).toMatchObject({ + expect(result.candidates[1]).toMatchObject({ kind: 'ready', targetId: 'task:AGT-PLAN', source: 'planning', @@ -457,10 +458,11 @@ describe('AgentBriefingService', () => { makeDoctor(), ); - const candidates = await service.next(5); + const result = await service.next(5); - expect(candidates).toHaveLength(2); - expect(candidates[0]).toMatchObject({ + expect(result.diagnostics).toEqual([]); + expect(result.candidates).toHaveLength(2); + expect(result.candidates[0]).toMatchObject({ kind: 'merge', targetId: 'submission:AGT-MERGE', source: 'submission', @@ -468,7 +470,7 @@ describe('AgentBriefingService', () => { validationCode: 'requires-additional-input', args: { intoRef: 'main' }, }); - expect(candidates[1]).toMatchObject({ + expect(result.candidates[1]).toMatchObject({ kind: 'review', targetId: 'patchset:AGT-REVIEW', source: 'submission', diff --git a/test/unit/AgentCommands.test.ts b/test/unit/AgentCommands.test.ts index 286c0b6..26e5596 100644 --- a/test/unit/AgentCommands.test.ts +++ b/test/unit/AgentCommands.test.ts @@ -131,25 +131,28 @@ describe('agent act command', () => { }); it('emits a JSON next-candidate list', async () => { - mocks.nextCandidates.mockResolvedValue([ - { - kind: 'claim', - targetId: 'task:AGT-001', - args: {}, - reason: 'Quest is in READY and can be claimed immediately.', - confidence: 0.98, - requiresHumanApproval: false, - dryRunSummary: 'Move the quest into IN_PROGRESS and assign it to the current agent.', - blockedBy: [], - allowed: true, - underlyingCommand: 'xyph claim task:AGT-001', - sideEffects: ['status -> IN_PROGRESS'], - validationCode: null, - questTitle: 'Agent native quest', - questStatus: 'READY', - source: 'frontier', - }, - ]); + mocks.nextCandidates.mockResolvedValue({ + candidates: [ + { + kind: 'claim', + targetId: 'task:AGT-001', + args: {}, + reason: 'Quest is in READY and can be claimed immediately.', + confidence: 0.98, + requiresHumanApproval: false, + dryRunSummary: 'Move the quest into IN_PROGRESS and assign it to the current agent.', + blockedBy: [], + allowed: true, + underlyingCommand: 'xyph claim task:AGT-001', + sideEffects: ['status -> IN_PROGRESS'], + validationCode: null, + questTitle: 'Agent native quest', + questStatus: 'READY', + source: 'frontier', + }, + ], + diagnostics: [], + }); const ctx = makeCtx(); const program = new Command(); @@ -161,6 +164,7 @@ describe('agent act command', () => { expect(ctx.jsonOut).toHaveBeenCalledWith({ success: true, command: 'next', + diagnostics: [], data: { candidates: [ { diff --git a/test/unit/DashboardTraceCommand.test.ts b/test/unit/DashboardTraceCommand.test.ts index ac03905..00f044d 100644 --- a/test/unit/DashboardTraceCommand.test.ts +++ b/test/unit/DashboardTraceCommand.test.ts @@ -1,11 +1,27 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { Command } from 'commander'; import type { CliContext } from '../../src/cli/context.js'; -import { registerDashboardCommands } from '../../src/cli/commands/dashboard.js'; import { makeSnapshot } from '../helpers/snapshot.js'; const fetchSnapshot = vi.fn(); const filterSnapshot = vi.fn(); +const doctorRun = vi.fn(); +const roadmapCtor = vi.fn(); + +vi.mock('../../src/domain/services/DoctorService.js', () => ({ + DoctorService: vi.fn().mockImplementation(function MockDoctorService() { + return { + run: doctorRun, + }; + }), +})); + +vi.mock('../../src/infrastructure/adapters/WarpRoadmapAdapter.js', () => ({ + WarpRoadmapAdapter: vi.fn().mockImplementation(function MockWarpRoadmapAdapter(graphPort: unknown) { + roadmapCtor(graphPort); + return { mocked: true }; + }), +})); vi.mock('../../src/infrastructure/GraphContext.js', () => ({ createGraphContext: vi.fn(() => ({ @@ -19,6 +35,8 @@ vi.mock('../../src/infrastructure/GraphContext.js', () => ({ })), })); +import { registerDashboardCommands } from '../../src/cli/commands/dashboard.js'; + function makeCtx(): CliContext { return { agentId: 'human.test', @@ -43,6 +61,46 @@ function makeCtx(): CliContext { describe('dashboard trace view JSON', () => { beforeEach(() => { vi.clearAllMocks(); + doctorRun.mockResolvedValue({ + status: 'ok', + healthy: true, + blocking: false, + asOf: 1, + graphMeta: null, + auditedStatuses: ['PLANNED', 'READY'], + counts: { + campaigns: 0, + quests: 0, + intents: 0, + scrolls: 0, + approvals: 0, + submissions: 0, + patchsets: 0, + reviews: 0, + decisions: 0, + stories: 0, + requirements: 0, + criteria: 0, + evidence: 0, + policies: 0, + suggestions: 0, + documents: 0, + comments: 0, + }, + summary: { + issueCount: 0, + blockingIssueCount: 0, + errorCount: 0, + warningCount: 0, + danglingEdges: 0, + orphanNodes: 0, + readinessGaps: 0, + sovereigntyViolations: 0, + governedCompletionGaps: 0, + }, + issues: [], + diagnostics: [], + }); }); it('includes policies in the trace JSON envelope', async () => { @@ -102,8 +160,24 @@ describe('dashboard trace view JSON', () => { expect(ctx.jsonOut).toHaveBeenCalledWith({ success: true, command: 'status', + diagnostics: [], data: { view: 'trace', + health: { + status: 'ok', + blocking: false, + summary: { + issueCount: 0, + blockingIssueCount: 0, + errorCount: 0, + warningCount: 0, + danglingEdges: 0, + orphanNodes: 0, + readinessGaps: 0, + sovereigntyViolations: 0, + governedCompletionGaps: 0, + }, + }, stories: snapshot.stories, requirements: snapshot.requirements, criteria: snapshot.criteria, @@ -196,7 +270,12 @@ describe('dashboard trace view JSON', () => { expect(ctx.jsonOut).toHaveBeenCalledWith(expect.objectContaining({ success: true, command: 'status', + diagnostics: [], data: expect.objectContaining({ + health: expect.objectContaining({ + status: 'ok', + blocking: false, + }), summary: expect.objectContaining({ evidenced: 2, satisfied: 0, diff --git a/test/unit/IntakeCommands.test.ts b/test/unit/IntakeCommands.test.ts new file mode 100644 index 0000000..b0d3b0b --- /dev/null +++ b/test/unit/IntakeCommands.test.ts @@ -0,0 +1,100 @@ +import { Command } from 'commander'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { CliContext } from '../../src/cli/context.js'; +import { registerIntakeCommands } from '../../src/cli/commands/intake.js'; + +const mocks = vi.hoisted(() => ({ + readinessAssess: vi.fn(), + WarpRoadmapAdapter: vi.fn(), +})); + +vi.mock('../../src/domain/services/ReadinessService.js', () => ({ + ReadinessService: class ReadinessService { + assess(questId: string) { + return mocks.readinessAssess(questId); + } + }, +})); + +vi.mock('../../src/infrastructure/adapters/WarpRoadmapAdapter.js', () => ({ + WarpRoadmapAdapter: function WarpRoadmapAdapter(graphPort: unknown) { + mocks.WarpRoadmapAdapter(graphPort); + }, +})); + +function makeCtx(): CliContext { + return { + agentId: 'human.architect', + identity: { agentId: 'human.architect', source: 'default', origin: null }, + json: true, + graphPort: {} as CliContext['graphPort'], + style: {} as CliContext['style'], + ok: vi.fn(), + warn: vi.fn(), + muted: vi.fn(), + print: vi.fn(), + fail: vi.fn((msg: string) => { + throw new Error(msg); + }), + failWithData: vi.fn((msg: string) => { + throw new Error(msg); + }), + jsonOut: vi.fn(), + } as unknown as CliContext; +} + +describe('intake ready command', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('emits structured readiness diagnostics when the quest cannot enter READY', async () => { + mocks.readinessAssess.mockResolvedValue({ + valid: false, + questId: 'task:READY-001', + status: 'PLANNED', + taskKind: 'delivery', + intentId: 'intent:TRACE', + campaignId: 'campaign:TRACE', + unmet: [{ + code: 'missing-criterion', + field: 'traceability', + nodeId: 'req:READY-001', + message: 'req:READY-001 needs at least one has-criterion edge before task:READY-001 can become READY', + }], + }); + + const ctx = makeCtx(); + const program = new Command(); + registerIntakeCommands(program, ctx); + + await expect( + program.parseAsync(['ready', 'task:READY-001'], { from: 'user' }), + ).rejects.toThrow('[NOT_READY] task:READY-001 does not satisfy readiness requirements'); + + expect(ctx.failWithData).toHaveBeenCalledWith( + '[NOT_READY] task:READY-001 does not satisfy readiness requirements', + { + valid: false, + id: 'task:READY-001', + status: 'PLANNED', + taskKind: 'delivery', + intentId: 'intent:TRACE', + campaignId: 'campaign:TRACE', + unmet: [{ + code: 'missing-criterion', + field: 'traceability', + nodeId: 'req:READY-001', + message: 'req:READY-001 needs at least one has-criterion edge before task:READY-001 can become READY', + }], + }, + [ + expect.objectContaining({ + code: 'readiness-missing-criterion', + category: 'readiness', + subjectId: 'req:READY-001', + }), + ], + ); + }); +}); diff --git a/test/unit/SignedSettlementCommands.test.ts b/test/unit/SignedSettlementCommands.test.ts index 854dbe9..4cc8d4c 100644 --- a/test/unit/SignedSettlementCommands.test.ts +++ b/test/unit/SignedSettlementCommands.test.ts @@ -1,6 +1,7 @@ import { Command } from 'commander'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { CliContext, JsonEnvelope } from '../../src/cli/context.js'; +import type { Diagnostic } from '../../src/domain/models/diagnostics.js'; import type { EntityDetail } from '../../src/domain/models/dashboard.js'; import { allowUnsignedScrollsForSettlement, @@ -214,8 +215,15 @@ function createJsonCtx(overrides: Partial = {}): CliContext { process.exit(1); return undefined as never; }, - failWithData(msg: string, data: Record): never { - console.log(JSON.stringify({ success: false, error: msg, data })); + failWithData(msg: string, data: Record, diagnostics?: Diagnostic[]): never { + console.log(JSON.stringify({ + success: false, + error: msg, + data, + ...(diagnostics === undefined || diagnostics.length === 0 + ? {} + : { diagnostics }), + })); process.exit(1); return undefined as never; }, @@ -439,6 +447,12 @@ describe('signed settlement enforcement', () => { expect(output).toMatchObject({ success: false, error: expect.stringContaining('latest submission submission:Q1 is OPEN'), + diagnostics: [ + expect.objectContaining({ + code: 'settlement-approved-submission-required', + category: 'workflow', + }), + ], data: { action: 'seal', questId: 'task:Q1', @@ -564,6 +578,12 @@ describe('signed settlement enforcement', () => { expect(output).toMatchObject({ success: false, error: expect.stringContaining('policy policy:TRACE blocks settlement'), + diagnostics: [ + expect.objectContaining({ + code: 'settlement-governed-work-linked-only', + category: 'workflow', + }), + ], data: { submissionId: 'submission:Q1', action: 'merge', From 9dd9abe34fdf62d90350e4c0d79aaed422fa8dd2 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 14 Mar 2026 02:14:17 -0700 Subject: [PATCH 22/22] Add first-class quest priority --- docs/canonical/GRAPH_SCHEMA.md | 1 + docs/canonical/ROADMAP_PROTOCOL.md | 3 +- src/cli/commands/agent.ts | 7 +++- src/cli/commands/ingest.ts | 21 +++++++++- src/cli/commands/intake.ts | 38 ++++++++++++++++--- src/cli/commands/show.ts | 2 +- src/cli/commands/wizards.ts | 1 + src/domain/entities/Quest.ts | 30 +++++++++++++++ src/domain/models/dashboard.ts | 3 +- src/domain/services/AgentActionService.ts | 26 +++++++++++-- src/domain/services/AgentContextService.ts | 3 +- src/domain/services/AgentRecommender.ts | 1 + src/infrastructure/GraphContext.ts | 3 ++ .../adapters/WarpIntakeAdapter.ts | 20 ++++++++-- .../adapters/WarpRoadmapAdapter.ts | 5 +++ src/ports/IntakePort.ts | 4 +- test/helpers/snapshot.ts | 1 + test/integration/WarpRoadmapAdapter.test.ts | 2 + test/unit/Quest.test.ts | 22 ++++++++++- 19 files changed, 171 insertions(+), 22 deletions(-) diff --git a/docs/canonical/GRAPH_SCHEMA.md b/docs/canonical/GRAPH_SCHEMA.md index 3ecbaa4..2766e16 100644 --- a/docs/canonical/GRAPH_SCHEMA.md +++ b/docs/canonical/GRAPH_SCHEMA.md @@ -90,6 +90,7 @@ All properties use **snake_case** in the WARP graph. Timestamps are Unix epoch n | `title` | string | quest command | ≥5 chars. | | `status` | QuestStatus | lifecycle | See valid values below. | | `hours` | number | quest command | ≥0, default 0. | +| `priority` | string | intake/shape/ingest | `P0` through `P5`. Defaults to `P3`. | | `description` | string | intake/quest command | Optional durable summary/body preview. | | `task_kind` | string | intake/quest command | `delivery`, `spike`, `maintenance`, or `ops`. Defaults to `delivery`. | | `assigned_to` | string | claim command | Principal ID (e.g., `agent.hal`). | diff --git a/docs/canonical/ROADMAP_PROTOCOL.md b/docs/canonical/ROADMAP_PROTOCOL.md index 7cf39e2..168e9b0 100644 --- a/docs/canonical/ROADMAP_PROTOCOL.md +++ b/docs/canonical/ROADMAP_PROTOCOL.md @@ -24,7 +24,8 @@ - `seal` and auto-sealing `merge` must reject governed work when the applied policy disallows manual settlement and computed completion is still incomplete. ## Authoring Workflow -- Use `xyph shape ` while a quest is `BACKLOG` or `PLANNED` to enrich durable metadata such as `description` and `task_kind`. +- Every quest carries an explicit operational priority from `P0` through `P5`; missing priority reads as `P3`. +- Use `xyph shape ` while a quest is `BACKLOG` or `PLANNED` to enrich durable metadata such as `description`, `task_kind`, and `priority`. - Use `xyph packet ` to create or link the minimal story → requirement → criterion chain for delivery-oriented work. - `ready` remains strict: shaping and packet authoring are the sanctioned preparation path, not an escape hatch around readiness validation. diff --git a/src/cli/commands/agent.ts b/src/cli/commands/agent.ts index 6814d55..ac4b629 100644 --- a/src/cli/commands/agent.ts +++ b/src/cli/commands/agent.ts @@ -2,7 +2,7 @@ import type { Command } from 'commander'; import type { CliContext } from '../context.js'; import { createErrorHandler } from '../errorHandler.js'; import { renderDiagnosticsLines } from '../renderDiagnostics.js'; -import { VALID_TASK_KINDS } from '../../domain/entities/Quest.js'; +import { VALID_QUEST_PRIORITIES, VALID_TASK_KINDS } from '../../domain/entities/Quest.js'; import { VALID_REQUIREMENT_KINDS, VALID_REQUIREMENT_PRIORITIES, @@ -26,6 +26,7 @@ import type { EntityDetail } from '../../domain/models/dashboard.js'; interface ActOptions { dryRun?: boolean; description?: string; + taskPriority?: string; title?: string; rationale?: string; artifact?: string; @@ -56,6 +57,7 @@ interface ActOptions { function buildActionArgs(opts: ActOptions): Record { const args: Record = {}; if (opts.description !== undefined) args['description'] = opts.description.trim(); + if (opts.taskPriority !== undefined) args['taskPriority'] = opts.taskPriority.trim(); if (opts.title !== undefined) args['title'] = opts.title.trim(); if (opts.rationale !== undefined) args['rationale'] = opts.rationale.trim(); if (opts.artifact !== undefined) args['artifactHash'] = opts.artifact.trim(); @@ -137,7 +139,7 @@ function renderAgentContext( if (detail.questDetail) { const quest = detail.questDetail.quest; lines.push(`${quest.title} [${quest.status}]`); - lines.push(`kind: ${quest.taskKind ?? 'delivery'} hours: ${quest.hours}`); + lines.push(`priority: ${quest.priority ?? 'P3'} kind: ${quest.taskKind ?? 'delivery'} hours: ${quest.hours}`); if (quest.description) { lines.push(''); lines.push(quest.description); @@ -526,6 +528,7 @@ export function registerAgentCommands(program: Command, ctx: CliContext): void { .description('Execute a validated routine action through the agent action kernel') .option('--dry-run', 'Validate and normalize without mutating graph or workspace') .option('--description ', 'Description for shape or submit') + .option('--task-priority ', `Quest priority for shape (${[...VALID_QUEST_PRIORITIES].join(' | ')})`) .option('--title ', 'Title for handoff') .option('--rationale ', 'Rationale for seal or merge') .option('--artifact ', 'Artifact hash for seal') diff --git a/src/cli/commands/ingest.ts b/src/cli/commands/ingest.ts index dca393a..f7dbbb7 100644 --- a/src/cli/commands/ingest.ts +++ b/src/cli/commands/ingest.ts @@ -2,7 +2,12 @@ import type { Command } from 'commander'; import type { CliContext } from '../context.js'; import { createErrorHandler } from '../errorHandler.js'; import { assertMinLength, assertPrefix, parseHours } from '../validators.js'; -import { VALID_TASK_KINDS, type QuestKind } from '../../domain/entities/Quest.js'; +import { + VALID_QUEST_PRIORITIES, + VALID_TASK_KINDS, + type QuestKind, + type QuestPriority, +} from '../../domain/entities/Quest.js'; function resolveTaskKind(raw: string | undefined): QuestKind { const taskKind = raw ?? 'delivery'; @@ -12,6 +17,14 @@ function resolveTaskKind(raw: string | undefined): QuestKind { return taskKind as QuestKind; } +function resolveQuestPriority(raw: string | undefined): QuestPriority { + const priority = raw ?? 'P3'; + if (!VALID_QUEST_PRIORITIES.has(priority)) { + throw new Error(`--priority must be one of ${[...VALID_QUEST_PRIORITIES].join(', ')}`); + } + return priority as QuestPriority; +} + export function registerIngestCommands(program: Command, ctx: CliContext): void { const withErrorHandler = createErrorHandler(ctx); @@ -22,12 +35,14 @@ export function registerIngestCommands(program: Command, ctx: CliContext): void .requiredOption('--campaign ', 'Parent Campaign ID (use "none" to skip)') .option('--description ', 'Durable quest description/body preview') .option('--kind ', `Quest kind (${[...VALID_TASK_KINDS].join(' | ')})`) + .option('--priority ', `Quest priority (${[...VALID_QUEST_PRIORITIES].join(' | ')})`) .option('--hours ', 'Estimated human hours (PERT)', parseHours) .option('--intent ', 'Sovereign Intent node that authorizes this Quest (intent:* prefix)') - .action(withErrorHandler(async (id: string, opts: { title: string; campaign: string; description?: string; kind?: string; hours?: number; intent?: string }) => { + .action(withErrorHandler(async (id: string, opts: { title: string; campaign: string; description?: string; kind?: string; priority?: string; hours?: number; intent?: string }) => { assertPrefix(id, 'task:', 'Quest ID'); if (opts.description !== undefined) assertMinLength(opts.description.trim(), 5, '--description'); const taskKind = resolveTaskKind(opts.kind); + const priority = resolveQuestPriority(opts.priority); const intentId = opts.intent; if (!intentId) { @@ -45,6 +60,7 @@ export function registerIngestCommands(program: Command, ctx: CliContext): void .setProperty(id, 'title', opts.title) .setProperty(id, 'status', 'PLANNED') .setProperty(id, 'hours', opts.hours ?? 0) + .setProperty(id, 'priority', priority) .setProperty(id, 'task_kind', taskKind) .setProperty(id, 'type', 'task'); if (opts.description !== undefined) { @@ -67,6 +83,7 @@ export function registerIngestCommands(program: Command, ctx: CliContext): void campaign: opts.campaign, intent: intentId, description: opts.description?.trim() ?? null, + priority, taskKind, hours: opts.hours ?? 0, patch: sha, diff --git a/src/cli/commands/intake.ts b/src/cli/commands/intake.ts index 666e590..7d8db17 100644 --- a/src/cli/commands/intake.ts +++ b/src/cli/commands/intake.ts @@ -2,7 +2,12 @@ import type { Command } from 'commander'; import type { CliContext } from '../context.js'; import { createErrorHandler } from '../errorHandler.js'; import { assertPrefix, assertMinLength, assertPrefixOneOf, parseHours } from '../validators.js'; -import { VALID_TASK_KINDS, type QuestKind } from '../../domain/entities/Quest.js'; +import { + VALID_QUEST_PRIORITIES, + VALID_TASK_KINDS, + type QuestKind, + type QuestPriority, +} from '../../domain/entities/Quest.js'; import { collectReadinessDiagnostics } from '../../domain/services/DiagnosticService.js'; function resolveTaskKind(raw: string | undefined): QuestKind { @@ -13,6 +18,14 @@ function resolveTaskKind(raw: string | undefined): QuestKind { return taskKind as QuestKind; } +function resolveQuestPriority(raw: string | undefined): QuestPriority { + const priority = raw ?? 'P3'; + if (!VALID_QUEST_PRIORITIES.has(priority)) { + throw new Error(`--priority must be one of ${[...VALID_QUEST_PRIORITIES].join(', ')}`); + } + return priority as QuestPriority; +} + export function registerIntakeCommands(program: Command, ctx: CliContext): void { const withErrorHandler = createErrorHandler(ctx); @@ -23,13 +36,15 @@ export function registerIntakeCommands(program: Command, ctx: CliContext): void .requiredOption('--suggested-by ', 'Who is suggesting this task (human.* or agent.*)') .option('--description ', 'Durable quest description/body preview') .option('--kind ', `Quest kind (${[...VALID_TASK_KINDS].join(' | ')})`) + .option('--priority ', `Quest priority (${[...VALID_QUEST_PRIORITIES].join(' | ')})`) .option('--hours ', 'Estimated hours', parseHours) - .action(withErrorHandler(async (id: string, opts: { title: string; suggestedBy: string; description?: string; kind?: string; hours?: number }) => { + .action(withErrorHandler(async (id: string, opts: { title: string; suggestedBy: string; description?: string; kind?: string; priority?: string; hours?: number }) => { assertPrefix(id, 'task:', 'Task ID'); assertMinLength(opts.title, 5, '--title'); assertPrefixOneOf(opts.suggestedBy, ['human.', 'agent.'], '--suggested-by'); if (opts.description !== undefined) assertMinLength(opts.description.trim(), 5, '--description'); const taskKind = resolveTaskKind(opts.kind); + const priority = resolveQuestPriority(opts.priority); const graph = await ctx.graphPort.getGraph(); const now = Date.now(); @@ -40,6 +55,7 @@ export function registerIntakeCommands(program: Command, ctx: CliContext): void .setProperty(id, 'status', 'BACKLOG') .setProperty(id, 'hours', opts.hours ?? 0) .setProperty(id, 'type', 'task') + .setProperty(id, 'priority', priority) .setProperty(id, 'task_kind', taskKind) .setProperty(id, 'suggested_by', opts.suggestedBy) .setProperty(id, 'suggested_at', now); @@ -57,6 +73,7 @@ export function registerIntakeCommands(program: Command, ctx: CliContext): void status: 'BACKLOG', suggestedBy: opts.suggestedBy, description: opts.description?.trim() ?? null, + priority, taskKind, hours: opts.hours ?? 0, patch: sha, @@ -77,15 +94,18 @@ export function registerIntakeCommands(program: Command, ctx: CliContext): void .option('--campaign ', 'Campaign to assign (optional, assignable later)') .option('--description ', 'Durable quest description/body preview') .option('--kind ', `Quest kind (${[...VALID_TASK_KINDS].join(' | ')})`) - .action(withErrorHandler(async (id: string, opts: { intent: string; campaign?: string; description?: string; kind?: string }) => { + .option('--priority ', `Quest priority (${[...VALID_QUEST_PRIORITIES].join(' | ')})`) + .action(withErrorHandler(async (id: string, opts: { intent: string; campaign?: string; description?: string; kind?: string; priority?: string }) => { const { WarpIntakeAdapter } = await import('../../infrastructure/adapters/WarpIntakeAdapter.js'); if (opts.description !== undefined) assertMinLength(opts.description.trim(), 5, '--description'); const taskKind = resolveTaskKind(opts.kind); + const priority = opts.priority !== undefined ? resolveQuestPriority(opts.priority) : undefined; const intake = new WarpIntakeAdapter(ctx.graphPort, ctx.agentId); const sha = await intake.promote(id, opts.intent, opts.campaign, { description: opts.description?.trim(), taskKind, + priority, }); if (ctx.json) { @@ -96,6 +116,7 @@ export function registerIntakeCommands(program: Command, ctx: CliContext): void intent: opts.intent, campaign: opts.campaign ?? null, description: opts.description?.trim() ?? null, + priority: priority ?? null, taskKind, patch: sha, }, @@ -169,22 +190,25 @@ export function registerIntakeCommands(program: Command, ctx: CliContext): void .description('Enrich a BACKLOG or PLANNED task with durable metadata before READY') .option('--description ', 'Durable quest description/body preview') .option('--kind ', `Quest kind (${[...VALID_TASK_KINDS].join(' | ')})`) - .action(withErrorHandler(async (id: string, opts: { description?: string; kind?: string }) => { + .option('--priority ', `Quest priority (${[...VALID_QUEST_PRIORITIES].join(' | ')})`) + .action(withErrorHandler(async (id: string, opts: { description?: string; kind?: string; priority?: string }) => { const { WarpIntakeAdapter } = await import('../../infrastructure/adapters/WarpIntakeAdapter.js'); const { WarpRoadmapAdapter } = await import('../../infrastructure/adapters/WarpRoadmapAdapter.js'); - if (opts.description === undefined && opts.kind === undefined) { - throw new Error('[MISSING_ARG] shape requires --description and/or --kind'); + if (opts.description === undefined && opts.kind === undefined && opts.priority === undefined) { + throw new Error('[MISSING_ARG] shape requires --description, --kind, and/or --priority'); } if (opts.description !== undefined) { assertMinLength(opts.description.trim(), 5, '--description'); } const taskKind = opts.kind !== undefined ? resolveTaskKind(opts.kind) : undefined; + const priority = opts.priority !== undefined ? resolveQuestPriority(opts.priority) : undefined; const intake = new WarpIntakeAdapter(ctx.graphPort, ctx.agentId); const sha = await intake.shape(id, { description: opts.description?.trim(), taskKind, + priority, }); const roadmap = new WarpRoadmapAdapter(ctx.graphPort); @@ -198,6 +222,7 @@ export function registerIntakeCommands(program: Command, ctx: CliContext): void id, status: quest?.status ?? null, description: quest?.description ?? null, + priority: quest?.priority ?? null, taskKind: quest?.taskKind ?? null, patch: sha, }, @@ -207,6 +232,7 @@ export function registerIntakeCommands(program: Command, ctx: CliContext): void ctx.ok(`[OK] Task ${id} shaped for planning.`); if (quest?.description) ctx.muted(` Description: ${quest.description}`); + if (quest?.priority) ctx.muted(` Priority: ${quest.priority}`); if (quest?.taskKind) ctx.muted(` Kind: ${quest.taskKind}`); ctx.muted(` Patch: ${sha}`); })); diff --git a/src/cli/commands/show.ts b/src/cli/commands/show.ts index d6bbab3..1212847 100644 --- a/src/cli/commands/show.ts +++ b/src/cli/commands/show.ts @@ -118,7 +118,7 @@ function renderQuestDetail( const { quest } = detail; lines.push(`${quest.id} ${quest.title} [${quest.status}]`); - lines.push(`kind: ${quest.taskKind ?? 'delivery'} hours: ${quest.hours}`); + lines.push(`priority: ${quest.priority ?? 'P3'} kind: ${quest.taskKind ?? 'delivery'} hours: ${quest.hours}`); if (quest.description) { lines.push(''); lines.push(quest.description); diff --git a/src/cli/commands/wizards.ts b/src/cli/commands/wizards.ts index a5f17ec..869c648 100644 --- a/src/cli/commands/wizards.ts +++ b/src/cli/commands/wizards.ts @@ -139,6 +139,7 @@ export function registerWizardCommands(program: Command, ctx: CliContext): void .setProperty(questId, 'title', title) .setProperty(questId, 'status', 'PLANNED') .setProperty(questId, 'hours', hours) + .setProperty(questId, 'priority', 'P3') .setProperty(questId, 'task_kind', taskKind) .setProperty(questId, 'type', 'task'); if (description.trim()) { diff --git a/src/domain/entities/Quest.ts b/src/domain/entities/Quest.ts index 30242c9..317c80f 100644 --- a/src/domain/entities/Quest.ts +++ b/src/domain/entities/Quest.ts @@ -43,10 +43,24 @@ export function normalizeQuestStatus(raw: string): QuestStatus { export type QuestType = 'task'; export type QuestKind = 'delivery' | 'spike' | 'maintenance' | 'ops'; +export type QuestPriority = 'P0' | 'P1' | 'P2' | 'P3' | 'P4' | 'P5'; export const VALID_TASK_KINDS: ReadonlySet = new Set([ 'delivery', 'spike', 'maintenance', 'ops', ]); +export const VALID_QUEST_PRIORITIES: ReadonlySet = new Set([ + 'P0', 'P1', 'P2', 'P3', 'P4', 'P5', +]); +export const DEFAULT_QUEST_PRIORITY: QuestPriority = 'P3'; + +const QUEST_PRIORITY_ORDER: Readonly> = { + P0: 0, + P1: 1, + P2: 2, + P3: 3, + P4: 4, + P5: 5, +}; export function normalizeQuestKind(raw: unknown): QuestKind { if (typeof raw === 'string' && VALID_TASK_KINDS.has(raw)) { @@ -55,6 +69,17 @@ export function normalizeQuestKind(raw: unknown): QuestKind { return 'delivery'; } +export function normalizeQuestPriority(raw: unknown): QuestPriority { + if (typeof raw === 'string' && VALID_QUEST_PRIORITIES.has(raw)) { + return raw as QuestPriority; + } + return DEFAULT_QUEST_PRIORITY; +} + +export function compareQuestPriority(a: QuestPriority, b: QuestPriority): number { + return QUEST_PRIORITY_ORDER[a] - QUEST_PRIORITY_ORDER[b]; +} + export function isExecutableQuestStatus(status: string): status is QuestStatus { return EXECUTABLE_QUEST_STATUSES.has(status as QuestStatus); } @@ -64,6 +89,7 @@ export interface QuestProps { title: string; status: QuestStatus; hours: number; + priority?: QuestPriority; description?: string; taskKind?: QuestKind; assignedTo?: string; @@ -80,6 +106,7 @@ export class Quest { public readonly title: string; public readonly status: QuestStatus; public readonly hours: number; + public readonly priority: QuestPriority; public readonly description?: string; public readonly taskKind: QuestKind; public readonly assignedTo?: string; @@ -110,11 +137,13 @@ export class Quest { } } const taskKind = normalizeQuestKind(props.taskKind); + const priority = normalizeQuestPriority(props.priority); this.id = props.id; this.title = props.title; this.status = props.status; this.hours = props.hours; + this.priority = priority; this.description = props.description?.trim(); this.taskKind = taskKind; this.assignedTo = props.assignedTo; @@ -132,6 +161,7 @@ export class Quest { title: this.title, status: this.status, hours: this.hours, + priority: this.priority, description: this.description, taskKind: this.taskKind, assignedTo: this.assignedTo, diff --git a/src/domain/models/dashboard.ts b/src/domain/models/dashboard.ts index 26949a8..75ae045 100644 --- a/src/domain/models/dashboard.ts +++ b/src/domain/models/dashboard.ts @@ -3,7 +3,7 @@ * No external dependencies — only TypeScript shapes. */ -import type { QuestKind, QuestStatus } from '../entities/Quest.js'; +import type { QuestKind, QuestPriority, QuestStatus } from '../entities/Quest.js'; import type { ApprovalGateStatus, ApprovalGateTrigger } from '../entities/ApprovalGate.js'; import type { SubmissionStatus, ReviewVerdict, DecisionKind } from '../entities/Submission.js'; import type { RequirementKind, RequirementPriority } from '../entities/Requirement.js'; @@ -48,6 +48,7 @@ export interface QuestNode { title: string; status: QuestStatus; hours: number; + priority?: QuestPriority; description?: string; taskKind?: QuestKind; campaignId?: string; diff --git a/src/domain/services/AgentActionService.ts b/src/domain/services/AgentActionService.ts index 1ec0a23..400d742 100644 --- a/src/domain/services/AgentActionService.ts +++ b/src/domain/services/AgentActionService.ts @@ -1,7 +1,12 @@ import { randomUUID } from 'node:crypto'; import type { GraphPort } from '../../ports/GraphPort.js'; import type { RoadmapQueryPort } from '../../ports/RoadmapPort.js'; -import { VALID_TASK_KINDS, type QuestKind } from '../entities/Quest.js'; +import { + VALID_QUEST_PRIORITIES, + VALID_TASK_KINDS, + type QuestKind, + type QuestPriority, +} from '../entities/Quest.js'; import { VALID_REQUIREMENT_KINDS, VALID_REQUIREMENT_PRIORITIES, @@ -86,6 +91,7 @@ interface ShapeAction { targetId: string; description?: string; taskKind?: QuestKind; + priority?: QuestPriority; } interface PacketAction { @@ -373,10 +379,12 @@ export class AgentActionValidator { : undefined; const taskKindRaw = request.args['taskKind']; const taskKind = typeof taskKindRaw === 'string' ? taskKindRaw : undefined; + const taskPriorityRaw = request.args['taskPriority']; + const taskPriority = typeof taskPriorityRaw === 'string' ? taskPriorityRaw.trim() : undefined; - if (descriptionRaw === undefined && taskKind === undefined) { + if (descriptionRaw === undefined && taskKind === undefined && taskPriority === undefined) { return failAssessment(request, 'invalid-args', [ - 'shape requires description and/or taskKind', + 'shape requires description, taskKind, and/or taskPriority', ]); } if (descriptionRaw !== undefined && descriptionRaw.length < 5) { @@ -389,6 +397,11 @@ export class AgentActionValidator { `taskKind must be one of ${[...VALID_TASK_KINDS].join(', ')}`, ]); } + if (taskPriority !== undefined && !VALID_QUEST_PRIORITIES.has(taskPriority)) { + return failAssessment(request, 'invalid-args', [ + `taskPriority must be one of ${[...VALID_QUEST_PRIORITIES].join(', ')}`, + ]); + } try { await this.intake.validateShape(request.targetId); @@ -398,11 +411,13 @@ export class AgentActionValidator { normalizedArgs: { description: descriptionRaw ?? null, taskKind: taskKind ?? null, + taskPriority: taskPriority ?? null, }, underlyingCommand: `xyph shape ${request.targetId}`, sideEffects: [ ...(descriptionRaw !== undefined ? ['description -> updated'] : []), ...(taskKind !== undefined ? ['task_kind -> updated'] : []), + ...(taskPriority !== undefined ? ['priority -> updated'] : []), ], }); } @@ -414,15 +429,18 @@ export class AgentActionValidator { targetId: request.targetId, description: descriptionRaw, taskKind: taskKind as QuestKind | undefined, + priority: taskPriority as QuestPriority | undefined, }, { description: descriptionRaw ?? null, taskKind: taskKind ?? null, + taskPriority: taskPriority ?? null, }, `xyph shape ${request.targetId}`, [ ...(descriptionRaw !== undefined ? ['description -> updated'] : []), ...(taskKind !== undefined ? ['task_kind -> updated'] : []), + ...(taskPriority !== undefined ? ['priority -> updated'] : []), ], ); } @@ -1350,6 +1368,7 @@ export class AgentActionService { const sha = await intake.shape(action.targetId, { description: action.description, taskKind: action.taskKind, + priority: action.priority, }); const graph = await this.graphPort.getGraph(); const props = await graph.getNodeProps(action.targetId); @@ -1362,6 +1381,7 @@ export class AgentActionService { id: action.targetId, status: typeof props?.['status'] === 'string' ? props['status'] : null, description: typeof props?.['description'] === 'string' ? props['description'] : null, + priority: typeof props?.['priority'] === 'string' ? props['priority'] : null, taskKind: typeof props?.['task_kind'] === 'string' ? props['task_kind'] : null, }, }; diff --git a/src/domain/services/AgentContextService.ts b/src/domain/services/AgentContextService.ts index 9229c08..b348713 100644 --- a/src/domain/services/AgentContextService.ts +++ b/src/domain/services/AgentContextService.ts @@ -1,4 +1,4 @@ -import { isExecutableQuestStatus } from '../entities/Quest.js'; +import { DEFAULT_QUEST_PRIORITY, isExecutableQuestStatus } from '../entities/Quest.js'; import type { Diagnostic } from '../models/diagnostics.js'; import type { EntityDetail, GraphSnapshot, QuestNode } from '../models/dashboard.js'; import type { GraphPort } from '../../ports/GraphPort.js'; @@ -30,6 +30,7 @@ export function toAgentQuestRef(quest: QuestNode): AgentQuestRef { title: quest.title, status: quest.status, hours: quest.hours, + priority: quest.priority ?? DEFAULT_QUEST_PRIORITY, taskKind: quest.taskKind, assignedTo: quest.assignedTo, }; diff --git a/src/domain/services/AgentRecommender.ts b/src/domain/services/AgentRecommender.ts index caddf29..d5b09bf 100644 --- a/src/domain/services/AgentRecommender.ts +++ b/src/domain/services/AgentRecommender.ts @@ -17,6 +17,7 @@ export interface AgentQuestRef { title: string; status: string; hours: number; + priority?: string; taskKind?: string; assignedTo?: string; } diff --git a/src/infrastructure/GraphContext.ts b/src/infrastructure/GraphContext.ts index 42c6c2d..420be9d 100644 --- a/src/infrastructure/GraphContext.ts +++ b/src/infrastructure/GraphContext.ts @@ -14,6 +14,7 @@ import type WarpGraph from '@git-stunts/git-warp'; import type { QueryResultV1, AggregateResult } from '@git-stunts/git-warp'; import { + normalizeQuestPriority, VALID_STATUSES as VALID_QUEST_STATUSES, isExecutableQuestStatus, normalizeQuestKind, @@ -349,6 +350,7 @@ class GraphContextImpl implements GraphContext { } const assignedTo = n.props['assigned_to']; + const priority = n.props['priority']; const description = n.props['description']; const taskKind = n.props['task_kind']; const readyBy = n.props['ready_by']; @@ -367,6 +369,7 @@ class GraphContextImpl implements GraphContext { title, status: rawStatus as QuestStatus, hours: typeof hours === 'number' && Number.isFinite(hours) && hours >= 0 ? hours : 0, + priority: normalizeQuestPriority(priority), description: typeof description === 'string' ? description : undefined, taskKind: normalizeQuestKind(taskKind), campaignId, diff --git a/src/infrastructure/adapters/WarpIntakeAdapter.ts b/src/infrastructure/adapters/WarpIntakeAdapter.ts index 25ce7a4..dd53ed8 100644 --- a/src/infrastructure/adapters/WarpIntakeAdapter.ts +++ b/src/infrastructure/adapters/WarpIntakeAdapter.ts @@ -1,6 +1,6 @@ import type { IntakePort, PromoteOptions, ShapeOptions } from '../../ports/IntakePort.js'; import type { GraphPort } from '../../ports/GraphPort.js'; -import { VALID_TASK_KINDS } from '../../domain/entities/Quest.js'; +import { VALID_QUEST_PRIORITIES, VALID_TASK_KINDS } from '../../domain/entities/Quest.js'; import { IntakeService } from '../../domain/services/IntakeService.js'; import { ReadinessService } from '../../domain/services/ReadinessService.js'; import { WarpRoadmapAdapter } from './WarpRoadmapAdapter.js'; @@ -62,6 +62,10 @@ export class WarpIntakeAdapter implements IntakePort { if (!VALID_TASK_KINDS.has(taskKind)) { throw new Error(`[MISSING_ARG] --kind must be one of ${[...VALID_TASK_KINDS].join(', ')}`); } + const priority = opts?.priority; + if (priority !== undefined && !VALID_QUEST_PRIORITIES.has(priority)) { + throw new Error(`[MISSING_ARG] --priority must be one of ${[...VALID_QUEST_PRIORITIES].join(', ')}`); + } const existingDescription = props['description']; if ((typeof existingDescription !== 'string' || existingDescription.trim().length < 5) && description === undefined) { throw new Error('[MISSING_ARG] promote requires --description when the quest has no existing description'); @@ -78,6 +82,9 @@ export class WarpIntakeAdapter implements IntakePort { p.setProperty(questId, 'status', 'PLANNED') .setProperty(questId, 'task_kind', taskKind) .addEdge(questId, intentId, 'authorized-by'); + if (priority !== undefined) { + p.setProperty(questId, 'priority', priority); + } if (description !== undefined) { p.setProperty(questId, 'description', description); } @@ -95,11 +102,15 @@ export class WarpIntakeAdapter implements IntakePort { throw new Error('[MISSING_ARG] --description must be at least 5 characters'); } const taskKind = opts.taskKind; + const priority = opts.priority; if (taskKind !== undefined && !VALID_TASK_KINDS.has(taskKind)) { throw new Error(`[MISSING_ARG] --kind must be one of ${[...VALID_TASK_KINDS].join(', ')}`); } - if (description === undefined && taskKind === undefined) { - throw new Error('[MISSING_ARG] shape requires --description and/or --kind'); + if (priority !== undefined && !VALID_QUEST_PRIORITIES.has(priority)) { + throw new Error(`[MISSING_ARG] --priority must be one of ${[...VALID_QUEST_PRIORITIES].join(', ')}`); + } + if (description === undefined && taskKind === undefined && priority === undefined) { + throw new Error('[MISSING_ARG] shape requires --description, --kind, and/or --priority'); } const roadmap = new WarpRoadmapAdapter(this.graphPort); @@ -125,6 +136,9 @@ export class WarpIntakeAdapter implements IntakePort { if (taskKind !== undefined) { p.setProperty(questId, 'task_kind', taskKind); } + if (priority !== undefined) { + p.setProperty(questId, 'priority', priority); + } }); } diff --git a/src/infrastructure/adapters/WarpRoadmapAdapter.ts b/src/infrastructure/adapters/WarpRoadmapAdapter.ts index b11ee58..d8c945b 100644 --- a/src/infrastructure/adapters/WarpRoadmapAdapter.ts +++ b/src/infrastructure/adapters/WarpRoadmapAdapter.ts @@ -1,10 +1,12 @@ import type { RoadmapPort } from '../../ports/RoadmapPort.js'; import type WarpGraph from '@git-stunts/git-warp'; import { + DEFAULT_QUEST_PRIORITY, Quest, QuestType, VALID_STATUSES, normalizeQuestKind, + normalizeQuestPriority, normalizeQuestStatus, } from '../../domain/entities/Quest.js'; import { EdgeType } from '../../schema.js'; @@ -43,6 +45,7 @@ export class WarpRoadmapAdapter implements RoadmapPort { const assignedTo = props['assigned_to']; const claimedAt = props['claimed_at']; const completedAt = props['completed_at']; + const priority = props['priority']; const description = props['description']; const taskKind = props['task_kind']; const readyBy = props['ready_by']; @@ -54,6 +57,7 @@ export class WarpRoadmapAdapter implements RoadmapPort { title, status: normalized, hours: parsedHours, + priority: normalizeQuestPriority(priority), description: typeof description === 'string' ? description : undefined, taskKind: normalizeQuestKind(taskKind), assignedTo: typeof assignedTo === 'string' ? assignedTo : undefined, @@ -105,6 +109,7 @@ export class WarpRoadmapAdapter implements RoadmapPort { p.setProperty(quest.id, 'title', quest.title) .setProperty(quest.id, 'hours', quest.hours) + .setProperty(quest.id, 'priority', quest.priority ?? DEFAULT_QUEST_PRIORITY) .setProperty(quest.id, 'task_kind', quest.taskKind) .setProperty(quest.id, 'type', quest.type); diff --git a/src/ports/IntakePort.ts b/src/ports/IntakePort.ts index c2c48bc..dc0c4e4 100644 --- a/src/ports/IntakePort.ts +++ b/src/ports/IntakePort.ts @@ -1,13 +1,15 @@ -import type { QuestKind } from '../domain/entities/Quest.js'; +import type { QuestKind, QuestPriority } from '../domain/entities/Quest.js'; export interface PromoteOptions { description?: string; taskKind?: QuestKind; + priority?: QuestPriority; } export interface ShapeOptions { description?: string; taskKind?: QuestKind; + priority?: QuestPriority; } export interface IntakePort { diff --git a/test/helpers/snapshot.ts b/test/helpers/snapshot.ts index 94e96d6..71752d6 100644 --- a/test/helpers/snapshot.ts +++ b/test/helpers/snapshot.ts @@ -60,6 +60,7 @@ export function quest(overrides: Partial & { id: string; title: strin return { status: 'PLANNED', hours: 2, + priority: 'P3', taskKind: 'delivery', ...overrides, }; diff --git a/test/integration/WarpRoadmapAdapter.test.ts b/test/integration/WarpRoadmapAdapter.test.ts index 4c85d3d..6b4844c 100644 --- a/test/integration/WarpRoadmapAdapter.test.ts +++ b/test/integration/WarpRoadmapAdapter.test.ts @@ -36,6 +36,7 @@ describe('WarpRoadmapAdapter Integration', () => { title: 'Integration Task', status: 'BACKLOG', hours: 4, + priority: 'P1', description: 'Persisted quest description for integration coverage.', taskKind: 'ops', type: 'task', @@ -50,6 +51,7 @@ describe('WarpRoadmapAdapter Integration', () => { expect(retrieved).not.toBeNull(); expect(retrieved?.id).toBe('task:INT-001'); expect(retrieved?.title).toBe('Integration Task'); + expect(retrieved?.priority).toBe('P1'); expect(retrieved?.description).toBe('Persisted quest description for integration coverage.'); expect(retrieved?.taskKind).toBe('ops'); expect(retrieved?.originContext).toBe('intent-123'); diff --git a/test/unit/Quest.test.ts b/test/unit/Quest.test.ts index 904a02a..5a41112 100644 --- a/test/unit/Quest.test.ts +++ b/test/unit/Quest.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect } from 'vitest'; -import { Quest, normalizeQuestKind } from '../../src/domain/entities/Quest.js'; +import { + Quest, + normalizeQuestKind, + normalizeQuestPriority, +} from '../../src/domain/entities/Quest.js'; describe('Quest Entity', () => { it('should create a valid quest', () => { @@ -15,6 +19,7 @@ describe('Quest Entity', () => { expect(quest.id).toBe('task:001'); expect(quest.isDone()).toBe(false); expect(quest.taskKind).toBe('maintenance'); + expect(quest.priority).toBe('P3'); }); it('should identify a completed quest', () => { @@ -92,7 +97,22 @@ describe('Quest Entity', () => { }); expect(quest.taskKind).toBe('delivery'); + expect(quest.priority).toBe('P3'); expect(normalizeQuestKind(undefined)).toBe('delivery'); + expect(normalizeQuestPriority(undefined)).toBe('P3'); + }); + + it('accepts explicit quest priority', () => { + const quest = new Quest({ + id: 'task:009', + title: 'Priority Quest', + status: 'BACKLOG', + hours: 1, + priority: 'P1', + type: 'task', + }); + + expect(quest.priority).toBe('P1'); }); it('accepts READY as a first-class quest status', () => {