diff --git a/docs/canonical/GRAPH_SCHEMA.md b/docs/canonical/GRAPH_SCHEMA.md index 2eebfec3..d78cb2ab 100644 --- a/docs/canonical/GRAPH_SCHEMA.md +++ b/docs/canonical/GRAPH_SCHEMA.md @@ -30,6 +30,10 @@ Example: `task:BDK-001`, `campaign:BEDROCK`, `submission:abc123` | `req` | Requirement | Concrete requirement implemented by work. | `req:TRC-001` | | `criterion` | Criterion | Verifiable acceptance criterion for a requirement. | `criterion:TRC-001` | | `evidence` | Evidence | Evidence that verifies a criterion or links to a requirement. | `evidence:scan-TRC-001` | +| `constraint` | Constraint | Planning boundary that constrains delivery or a campaign. | `constraint:TRC-001` | +| `assumption` | Assumption | Believed-true planning premise that may require validation. | `assumption:TRC-001` | +| `risk` | Risk | Known unknown with likelihood, impact, and mitigation. | `risk:TRC-001` | +| `spike` | Spike | Time-boxed investigation or exploratory planning item. | `spike:TRC-001` | | `policy` | Policy | Campaign-scoped Definition of Done policy. | `policy:done-default` | | `config` | Config | Graph-resident operational configuration singleton. | `config:xyph` | | `suggestion` | Suggestion | Advisory intake family; `type` distinguishes trace-link vs AI suggestion semantics. | `suggestion:019xyz` | @@ -77,6 +81,11 @@ Example: `task:BDK-001`, `campaign:BEDROCK`, `submission:abc123` | `implements` | task/evidence → req | Quest-to-requirement lineage is canonical; analyzer-generated evidence may also attach directly to a requirement. | | `has-criterion` | req → criterion | Requirement owns one or more acceptance criteria. | | `verifies` | evidence → criterion | Evidence verifies a criterion. | +| `constrains` | constraint → req/campaign | Planning boundary constrains a requirement or campaign. | +| `assumes` | assumption → task/req | Planning premise depends on a work item or requirement. | +| `threatens` | risk → task/req | Known danger applies to a quest or requirement. | +| `informs` | spike → req | Investigation produces spec or requirement truth. | +| `investigates` | spike → risk/assumption | Investigation refines a planning premise or risk. | | `governs` | policy → campaign/milestone | Definition of Done policy governs a campaign or milestone. | | `documents` | spec/adr/note/brief → target | Durable narrative or brief context records linked material for a node. | | `comments-on` | comment → target | Append-only discussion attached to a node. | @@ -245,6 +254,55 @@ Legacy: Pre-VOC-001 `INBOX` values are normalized to `BACKLOG` at read time. - `verifies` → `criterion:` (canonical evidence-to-criterion link) - optional `implements` → `req:` (auxiliary requirement-level auto-link produced by analysis/suggestions) +### Constraint (`constraint:*`) + +| Property | Type | Set By | Notes | +|----------|------|--------|-------| +| `type` | `'constraint'` | constraint command | Required. | +| `description` | string | constraint command | ≥5 chars. | +| `threshold` | string | constraint command | Human-readable boundary, for example `250ms` or `no external oracles`. | +| `unit` | string | constraint command | Unit or boundary label, for example `ms`, `requests/sec`, or `scope`. | + +**Edges:** +- `constrains` → `req:` or `campaign:`/`milestone:` + +### Assumption (`assumption:*`) + +| Property | Type | Set By | Notes | +|----------|------|--------|-------| +| `type` | `'assumption'` | assumption command | Required. | +| `description` | string | assumption command | ≥5 chars. | +| `validated` | boolean | assumption command | Whether the assumption has been validated. | +| `validated_at` | number | assumption command | Optional validation timestamp. | + +**Edges:** +- `assumes` → `task:` or `req:` + +### Risk (`risk:*`) + +| Property | Type | Set By | Notes | +|----------|------|--------|-------| +| `type` | `'risk'` | risk command | Required. | +| `description` | string | risk command | ≥5 chars. | +| `likelihood` | number | risk command | Normalized likelihood from `0` to `1`. | +| `impact` | number | risk command | Normalized impact from `0` to `1`. | +| `mitigation` | string | risk command | Optional mitigation note. | + +**Edges:** +- `threatens` → `task:` or `req:` + +### Spike (`spike:*`) + +| Property | Type | Set By | Notes | +|----------|------|--------|-------| +| `type` | `'spike'` | spike command | Required. | +| `timebox_hours` | number | spike command | Investigation timebox. | +| `outcome` | string | spike command | Summary of the resulting knowledge or decision. | + +**Edges:** +- `informs` → `req:` +- `investigates` → `risk:` or `assumption:` + --- ### Policy (`policy:*`) diff --git a/docs/canonical/ONTOLOGY_CATALOG.md b/docs/canonical/ONTOLOGY_CATALOG.md index d233a192..354f8def 100644 --- a/docs/canonical/ONTOLOGY_CATALOG.md +++ b/docs/canonical/ONTOLOGY_CATALOG.md @@ -72,7 +72,7 @@ This is the product-wide ontology catalog. |--------|-------|----------| | Execution work | shared plan state | `task`, `campaign`, `milestone`, `intent` | | Narrative and specification | durable context and discussion | `spec`, `adr`, `note`, `comment` | -| Traceability and completion | requirement/evidence truth | `story`, `req`, `criterion`, `evidence`, `policy` | +| Traceability and completion | requirement/evidence truth | `story`, `req`, `criterion`, `evidence`, `constraint`, `assumption`, `risk`, `spike`, `policy` | | Review and settlement | change review and governed completion | `submission`, `patchset`, `review`, `decision`, `approval`, `artifact` | | Advisory intake | recommendations and suggestion queues | `suggestion`, `proposal`, `config` | | Shape-governance | governed cases and briefs | `case`, `brief`, scoped `decision` | @@ -107,6 +107,10 @@ This is the product-wide ontology catalog. | Requirement | `req:*` | `type: 'requirement'` | Implemented | Concrete requirement implemented by quests. | | Criterion | `criterion:*` | `type: 'criterion'` | Implemented | Acceptance criterion attached to a requirement. | | Evidence | `evidence:*` | `type: 'evidence'` | Implemented | Proof or linkage that bears on a criterion or requirement. | +| Constraint | `constraint:*` | `type: 'constraint'` | Implemented | Planning boundary that constrains a quest, requirement, or campaign. | +| Assumption | `assumption:*` | `type: 'assumption'` | Implemented | Believed-true premise that can be validated or invalidated. | +| Risk | `risk:*` | `type: 'risk'` | Implemented | Known unknown with likelihood, impact, and mitigation. | +| Spike | `spike:*` | `type: 'spike'` | Implemented | Time-boxed investigation that informs planning truth. | | Policy | `policy:*` | `type: 'policy'` | Implemented | Definition-of-Done policy applied at campaign scope. | ### Review And Settlement diff --git a/docs/canonical/TRACEABILITY.md b/docs/canonical/TRACEABILITY.md index 59cc7f5b..579a2046 100644 --- a/docs/canonical/TRACEABILITY.md +++ b/docs/canonical/TRACEABILITY.md @@ -9,7 +9,9 @@ ROADMAP_PROTOCOL.md states: DONE means "acceptance criteria met, evidence attach DATA_CONTRACTS.md specifies `userStory` on every task. VISION_NORTH_STAR.md declares: "trust, tests, and deployment safety are cryptographically provable." -None of this is implemented. This document specifies the graph model that makes it real. +The core chain is already implemented in the product. This document now +specifies the canonical graph model, including the planning qualifiers that +frame readiness and risk analysis. ## 2. The Traceability Chain @@ -44,7 +46,7 @@ criteria are modeled. | `evidence:` | Evidence | Proof or linkage for a criterion | `kind` (test / benchmark / manual / screenshot), `result` (pass / fail / linked), `produced_at`, `produced_by`, `artifact_hash` | | `constraint:` | Constraint | Non-functional boundary (perf, security, compat) | `description`, `threshold`, `unit` | | `assumption:` | Assumption | Believed-true condition that could invalidate work | `description`, `validated` (bool), `validated_at` | -| `risk:` | Risk | Known unknown with impact assessment | `description`, `likelihood`, `impact`, `mitigation` | +| `risk:` | Risk | Known unknown with impact assessment | `description`, `likelihood` (0..1), `impact` (0..1), `mitigation` | | `spike:` | Spike | Time-boxed investigation producing knowledge | `timebox_hours`, `outcome` | | `policy:` | Policy | Definition of Done / campaign-level rules | `conditions[]` | @@ -60,6 +62,7 @@ criteria are modeled. | `assumes` | Assumption → Task or Requirement | Validity dependency | | `threatens` | Risk → Task or Requirement | Known danger | | `informs` | Spike → Requirement | Investigation produces spec | +| `investigates` | Spike → Risk or Assumption | Investigation informs planning risk or hypothesis | | `governs` | Policy → Campaign | Definition of Done scope | ## 5. Computed Queries diff --git a/src/cli/commands/dashboard.ts b/src/cli/commands/dashboard.ts index ee732e9f..51e40db5 100644 --- a/src/cli/commands/dashboard.ts +++ b/src/cli/commands/dashboard.ts @@ -218,6 +218,7 @@ export function registerDashboardCommands(program: Command, ctx: CliContext): vo computeFailingCriteria, computeUntestedCriteria, computeCoverageRatio, + computePlanningGapSummary, } = await import('../../domain/services/TraceabilityAnalysis.js'); const reqSummaries = snapshot.requirements.map((r) => ({ @@ -241,6 +242,21 @@ export function registerDashboardCommands(program: Command, ctx: CliContext): vo const untestedCriteria = computeUntestedCriteria(critSummaries); const failingCriteria = computeFailingCriteria(critSummaries); const coverage = computeCoverageRatio(critSummaries); + const planningGaps = computePlanningGapSummary( + snapshot.assumptions.map((assumption) => ({ + id: assumption.id, + validated: assumption.validated, + validatedAt: assumption.validatedAt, + targetIds: assumption.targetIds, + })), + snapshot.risks.map((risk) => ({ + id: risk.id, + likelihood: risk.likelihood, + impact: risk.impact, + mitigation: risk.mitigation, + targetIds: risk.targetIds, + })), + ); const questCompletion = snapshot.quests .filter((quest) => quest.computedCompletion?.tracked || quest.computedCompletion?.discrepancy) .map((quest) => ({ @@ -287,17 +303,27 @@ export function registerDashboardCommands(program: Command, ctx: CliContext): vo criteria: snapshot.criteria, evidence: snapshot.evidence, policies: snapshot.policies, + constraints: snapshot.constraints, + assumptions: snapshot.assumptions, + risks: snapshot.risks, + spikes: snapshot.spikes, summary: { stories: snapshot.stories.length, requirements: snapshot.requirements.length, criteria: snapshot.criteria.length, policies: snapshot.policies.length, + constraints: snapshot.constraints.length, + assumptions: snapshot.assumptions.length, + risks: snapshot.risks.length, + spikes: snapshot.spikes.length, evidenced: coverage.evidenced, satisfied: coverage.satisfied, failing: coverage.failing, linkedOnly: coverage.linkedOnly, unevidenced: coverage.unevidenced, coverageRatio: coverage.ratio, + unvalidatedAssumptions: planningGaps.unvalidatedAssumptionIds.length, + unmitigatedRisks: planningGaps.unmitigatedRiskIds.length, computedCompleteQuests: questCompletion.filter((entry) => entry.computedCompletion?.complete).length, computedTrackedQuests: questCompletion.filter((entry) => entry.computedCompletion?.tracked).length, computedCompleteCampaigns: campaignCompletion.filter((entry) => entry.computedCompletion?.complete).length, @@ -308,6 +334,7 @@ export function registerDashboardCommands(program: Command, ctx: CliContext): vo unmetRequirements: unmetReqs, untestedCriteria, failingCriteria, + planningGaps, questCompletion, campaignCompletion, questDiscrepancies, @@ -324,10 +351,15 @@ export function registerDashboardCommands(program: Command, ctx: CliContext): vo criteria: snapshot.criteria, evidence: snapshot.evidence, policies: snapshot.policies, + constraints: snapshot.constraints, + assumptions: snapshot.assumptions, + risks: snapshot.risks, + spikes: snapshot.spikes, unmetRequirements: unmetReqs, untestedCriteria, failingCriteria, coverage, + planningGaps, questCompletion, campaignCompletion, questDiscrepancies, diff --git a/src/cli/commands/traceability.ts b/src/cli/commands/traceability.ts index 39613e20..e608a949 100644 --- a/src/cli/commands/traceability.ts +++ b/src/cli/commands/traceability.ts @@ -40,6 +40,36 @@ function parseCoverageThreshold(value: string): number { return parsed; } +function parseNonNegativeNumber(optionName: string): (value: string) => number { + return (value: string): number => { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 0) { + throw new InvalidArgumentError(`${optionName} must be a non-negative number. Got: '${value}'`); + } + return parsed; + }; +} + +function parseEpochMillis(optionName: string): (value: string) => number { + return (value: string): number => { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 0) { + throw new InvalidArgumentError(`${optionName} must be a valid Unix epoch timestamp. Got: '${value}'`); + } + return parsed; + }; +} + +function parseRatio(optionName: string): (value: string) => number { + return (value: string): number => { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 0 || parsed > 1) { + throw new InvalidArgumentError(`${optionName} must be a number between 0 and 1. Got: '${value}'`); + } + return parsed; + }; +} + function derivePacketId(prefix: 'story:' | 'req:' | 'criterion:', questId: string): string { return `${prefix}${questId.slice('task:'.length)}`; } @@ -301,6 +331,294 @@ export function registerTraceabilityCommands(program: Command, ctx: CliContext): ctx.ok(`[OK] Evidence ${id} created → verifies ${opts.criterion}. Patch: ${sha}`); })); + // --- constraint: create a planning boundary node --- + program + .command('constraint ') + .description('Create a constraint node (constraint:*)') + .requiredOption('--description ', 'Constraint description (min 5 chars)') + .requiredOption('--threshold ', 'Human-readable threshold or boundary') + .requiredOption('--unit ', 'Boundary unit or label') + .option('--requirement ', 'Attach to a requirement (adds constrains edge)') + .option('--campaign ', 'Attach to a campaign or milestone (adds constrains edge)') + .action(withErrorHandler(async (id: string, opts: { + description: string; + threshold: string; + unit: string; + requirement?: string; + campaign?: string; + }) => { + assertPrefix(id, 'constraint:', 'Constraint ID'); + assertMinLength(opts.description, 5, '--description'); + + if (opts.requirement) { + assertPrefix(opts.requirement, 'req:', '--requirement'); + } + if (opts.campaign) { + assertGovernCampaign(opts.campaign); + } + + const graph = await ctx.graphPort.getGraph(); + const targetChecks = [ + opts.requirement ? assertNodeExists(graph, opts.requirement, 'Requirement') : Promise.resolve(), + opts.campaign ? assertNodeExists(graph, opts.campaign, 'Campaign') : Promise.resolve(), + ]; + await Promise.all(targetChecks); + + const sha = await graph.patch((p) => { + p.addNode(id) + .setProperty(id, 'type', 'constraint') + .setProperty(id, 'description', opts.description) + .setProperty(id, 'threshold', opts.threshold) + .setProperty(id, 'unit', opts.unit); + + if (opts.requirement) { + p.addEdge(id, opts.requirement, 'constrains'); + } + if (opts.campaign) { + p.addEdge(id, opts.campaign, 'constrains'); + } + }); + + if (ctx.json) { + ctx.jsonOut({ + success: true, + command: 'constraint', + data: { + id, + description: opts.description, + threshold: opts.threshold, + unit: opts.unit, + requirement: opts.requirement ?? null, + campaign: opts.campaign ?? null, + patch: sha, + }, + }); + return; + } + + ctx.ok(`[OK] Constraint ${id} created. Patch: ${sha}`); + ctx.muted(` Description: ${opts.description}`); + })); + + // --- assumption: create a planning premise node --- + program + .command('assumption ') + .description('Create an assumption node (assumption:*)') + .requiredOption('--description ', 'Assumption description (min 5 chars)') + .option('--validated', 'Mark the assumption as validated') + .option('--validated-at ', 'Validation timestamp', parseEpochMillis('--validated-at')) + .option('--task ', 'Attach to a quest (adds assumes edge)') + .option('--requirement ', 'Attach to a requirement (adds assumes edge)') + .action(withErrorHandler(async (id: string, opts: { + description: string; + validated: boolean; + validatedAt?: number; + task?: string; + requirement?: string; + }) => { + assertPrefix(id, 'assumption:', 'Assumption ID'); + assertMinLength(opts.description, 5, '--description'); + + if (opts.task) { + assertPrefix(opts.task, 'task:', '--task'); + } + if (opts.requirement) { + assertPrefix(opts.requirement, 'req:', '--requirement'); + } + + const graph = await ctx.graphPort.getGraph(); + await Promise.all([ + opts.task ? assertNodeExists(graph, opts.task, 'Quest') : Promise.resolve(), + opts.requirement ? assertNodeExists(graph, opts.requirement, 'Requirement') : Promise.resolve(), + ]); + + const validatedAt = opts.validated ? (opts.validatedAt ?? Date.now()) : undefined; + const sha = await graph.patch((p) => { + p.addNode(id) + .setProperty(id, 'type', 'assumption') + .setProperty(id, 'description', opts.description) + .setProperty(id, 'validated', opts.validated); + + if (validatedAt !== undefined) { + p.setProperty(id, 'validated_at', validatedAt); + } + if (opts.task) { + p.addEdge(id, opts.task, 'assumes'); + } + if (opts.requirement) { + p.addEdge(id, opts.requirement, 'assumes'); + } + }); + + if (ctx.json) { + ctx.jsonOut({ + success: true, + command: 'assumption', + data: { + id, + description: opts.description, + validated: opts.validated, + validatedAt: validatedAt ?? null, + task: opts.task ?? null, + requirement: opts.requirement ?? null, + patch: sha, + }, + }); + return; + } + + ctx.ok(`[OK] Assumption ${id} created. Patch: ${sha}`); + ctx.muted(` Description: ${opts.description}`); + })); + + // --- risk: create a planning danger node --- + program + .command('risk ') + .description('Create a risk node (risk:*)') + .requiredOption('--description ', 'Risk description (min 5 chars)') + .requiredOption('--likelihood ', 'Normalized likelihood between 0 and 1', parseRatio('--likelihood')) + .requiredOption('--impact ', 'Normalized impact between 0 and 1', parseRatio('--impact')) + .option('--mitigation ', 'Optional mitigation note') + .option('--task ', 'Attach to a quest (adds threatens edge)') + .option('--requirement ', 'Attach to a requirement (adds threatens edge)') + .action(withErrorHandler(async (id: string, opts: { + description: string; + likelihood: number; + impact: number; + mitigation?: string; + task?: string; + requirement?: string; + }) => { + assertPrefix(id, 'risk:', 'Risk ID'); + assertMinLength(opts.description, 5, '--description'); + + if (opts.task) { + assertPrefix(opts.task, 'task:', '--task'); + } + if (opts.requirement) { + assertPrefix(opts.requirement, 'req:', '--requirement'); + } + + const graph = await ctx.graphPort.getGraph(); + await Promise.all([ + opts.task ? assertNodeExists(graph, opts.task, 'Quest') : Promise.resolve(), + opts.requirement ? assertNodeExists(graph, opts.requirement, 'Requirement') : Promise.resolve(), + ]); + + const sha = await graph.patch((p) => { + p.addNode(id) + .setProperty(id, 'type', 'risk') + .setProperty(id, 'description', opts.description) + .setProperty(id, 'likelihood', opts.likelihood) + .setProperty(id, 'impact', opts.impact); + + if (opts.mitigation) { + p.setProperty(id, 'mitigation', opts.mitigation); + } + if (opts.task) { + p.addEdge(id, opts.task, 'threatens'); + } + if (opts.requirement) { + p.addEdge(id, opts.requirement, 'threatens'); + } + }); + + if (ctx.json) { + ctx.jsonOut({ + success: true, + command: 'risk', + data: { + id, + description: opts.description, + likelihood: opts.likelihood, + impact: opts.impact, + mitigation: opts.mitigation ?? null, + task: opts.task ?? null, + requirement: opts.requirement ?? null, + patch: sha, + }, + }); + return; + } + + ctx.ok(`[OK] Risk ${id} created. Patch: ${sha}`); + ctx.muted(` Description: ${opts.description}`); + })); + + // --- spike: create a time-boxed investigation node --- + program + .command('spike ') + .description('Create a spike node (spike:*)') + .requiredOption('--timebox-hours ', 'Investigation timebox in hours', parseNonNegativeNumber('--timebox-hours')) + .requiredOption('--outcome ', 'Investigation outcome or summary') + .option('--requirement ', 'Inform a requirement (adds informs edge)') + .option('--risk ', 'Investigate a risk (adds investigates edge)') + .option('--assumption ', 'Investigate an assumption (adds investigates edge)') + .action(withErrorHandler(async (id: string, opts: { + timeboxHours: number; + outcome: string; + requirement?: string; + risk?: string; + assumption?: string; + }) => { + assertPrefix(id, 'spike:', 'Spike ID'); + assertMinLength(opts.outcome, 5, '--outcome'); + + if (opts.requirement) { + assertPrefix(opts.requirement, 'req:', '--requirement'); + } + if (opts.risk) { + assertPrefix(opts.risk, 'risk:', '--risk'); + } + if (opts.assumption) { + assertPrefix(opts.assumption, 'assumption:', '--assumption'); + } + + const graph = await ctx.graphPort.getGraph(); + await Promise.all([ + opts.requirement ? assertNodeExists(graph, opts.requirement, 'Requirement') : Promise.resolve(), + opts.risk ? assertNodeExists(graph, opts.risk, 'Risk') : Promise.resolve(), + opts.assumption ? assertNodeExists(graph, opts.assumption, 'Assumption') : Promise.resolve(), + ]); + + const sha = await graph.patch((p) => { + p.addNode(id) + .setProperty(id, 'type', 'spike') + .setProperty(id, 'timebox_hours', opts.timeboxHours) + .setProperty(id, 'outcome', opts.outcome); + + if (opts.requirement) { + p.addEdge(id, opts.requirement, 'informs'); + } + if (opts.risk) { + p.addEdge(id, opts.risk, 'investigates'); + } + if (opts.assumption) { + p.addEdge(id, opts.assumption, 'investigates'); + } + }); + + if (ctx.json) { + ctx.jsonOut({ + success: true, + command: 'spike', + data: { + id, + timeboxHours: opts.timeboxHours, + outcome: opts.outcome, + requirement: opts.requirement ?? null, + risk: opts.risk ?? null, + assumption: opts.assumption ?? null, + patch: sha, + }, + }); + return; + } + + ctx.ok(`[OK] Spike ${id} created. Patch: ${sha}`); + ctx.muted(` Outcome: ${opts.outcome}`); + })); + // --- policy: create a Definition of Done policy node --- program .command('policy ') diff --git a/src/domain/models/dashboard.ts b/src/domain/models/dashboard.ts index 1cf246d7..7484cf63 100644 --- a/src/domain/models/dashboard.ts +++ b/src/domain/models/dashboard.ts @@ -192,6 +192,42 @@ export interface PolicyNode { allowManualSeal: boolean; } +// --------------------------------------------------------------------------- +// Planning qualifier node types (M11 Phase 3) +// --------------------------------------------------------------------------- + +export interface ConstraintNode { + id: string; + description: string; + threshold: string; + unit: string; + targetIds: string[]; +} + +export interface AssumptionNode { + id: string; + description: string; + validated: boolean; + validatedAt?: number; + targetIds: string[]; +} + +export interface RiskNode { + id: string; + description: string; + likelihood: number; + impact: number; + mitigation?: string; + targetIds: string[]; +} + +export interface SpikeNode { + id: string; + timeboxHours: number; + outcome: string; + targetIds: string[]; +} + // --------------------------------------------------------------------------- // Suggestion node type (M11 Phase 4) // --------------------------------------------------------------------------- @@ -553,6 +589,10 @@ export interface GraphSnapshot { criteria: CriterionNode[]; evidence: EvidenceNode[]; policies: PolicyNode[]; + constraints: ConstraintNode[]; + assumptions: AssumptionNode[]; + risks: RiskNode[]; + spikes: SpikeNode[]; // Auto-linking suggestions (M11 Phase 4) suggestions: SuggestionNode[]; aiSuggestions: AiSuggestionNode[]; diff --git a/src/domain/services/DoctorService.ts b/src/domain/services/DoctorService.ts index 3c2d1f9e..0b0630b2 100644 --- a/src/domain/services/DoctorService.ts +++ b/src/domain/services/DoctorService.ts @@ -127,6 +127,10 @@ export interface DoctorCounts { criteria: number; evidence: number; policies: number; + constraints: number; + assumptions: number; + risks: number; + spikes: number; suggestions: number; documents: number; comments: number; @@ -199,7 +203,7 @@ export class DoctorService { onProgress({ stage: 'neighbors', - message: 'Scanning workflow, narrative, and comment node families.', + message: 'Scanning workflow, narrative, planning, and comment node families.', }); const [patchsetNodes, specNodes, adrNodes, noteNodes, commentNodes] = await Promise.all([ @@ -312,6 +316,10 @@ export class DoctorService { criteria: snapshot.criteria.length, evidence: snapshot.evidence.length, policies: snapshot.policies.length, + constraints: snapshot.constraints.length, + assumptions: snapshot.assumptions.length, + risks: snapshot.risks.length, + spikes: snapshot.spikes.length, suggestions: snapshot.suggestions.length, documents: specNodes.length + adrNodes.length + noteNodes.length, comments: commentNodes.length, diff --git a/src/domain/services/TraceabilityAnalysis.ts b/src/domain/services/TraceabilityAnalysis.ts index e5bfd594..1c416d21 100644 --- a/src/domain/services/TraceabilityAnalysis.ts +++ b/src/domain/services/TraceabilityAnalysis.ts @@ -22,6 +22,32 @@ export interface RequirementSummary { criterionIds: string[]; } +export interface ConstraintSummary { + id: string; + targetIds: string[]; +} + +export interface AssumptionSummary { + id: string; + validated: boolean; + validatedAt?: number; + targetIds: string[]; +} + +export interface RiskSummary { + id: string; + likelihood: number; + impact: number; + mitigation?: string; + targetIds: string[]; +} + +export interface SpikeSummary { + id: string; + timeboxHours: number; + targetIds: string[]; +} + export interface CriterionEvidenceSummary { id: string; result: EvidenceResult; @@ -189,6 +215,21 @@ export interface CoverageResult { ratio: number; } +export interface RiskHotspot { + id: string; + likelihood: number; + impact: number; + score: number; + targetIds: string[]; + mitigation?: string; +} + +export interface PlanningGapSummary { + unvalidatedAssumptionIds: string[]; + unmitigatedRiskIds: string[]; + riskHotspots: RiskHotspot[]; +} + /** * Computes the fraction of criteria that have at least one piece of evidence. * Returns { evidenced, total, ratio } where ratio ∈ [0, 1]. @@ -316,3 +357,47 @@ export function computeCompletionSummary( discrepancy: tracked ? computeDiscrepancy(manualComplete, complete) : undefined, }; } + +export function computeUnvalidatedAssumptions( + assumptions: AssumptionSummary[], +): string[] { + return assumptions + .filter((assumption) => !assumption.validated) + .map((assumption) => assumption.id); +} + +export function computeRiskHotspots( + risks: RiskSummary[], +): RiskHotspot[] { + return risks + .map((risk) => ({ + id: risk.id, + likelihood: risk.likelihood, + impact: risk.impact, + score: risk.likelihood * risk.impact, + targetIds: risk.targetIds, + ...(typeof risk.mitigation === 'string' && risk.mitigation.trim().length > 0 + ? { mitigation: risk.mitigation } + : {}), + })) + .sort((left, right) => + right.score - left.score || + right.impact - left.impact || + right.likelihood - left.likelihood || + left.id.localeCompare(right.id), + ); +} + +export function computePlanningGapSummary( + assumptions: AssumptionSummary[], + risks: RiskSummary[], +): PlanningGapSummary { + const riskHotspots = computeRiskHotspots(risks); + return { + unvalidatedAssumptionIds: computeUnvalidatedAssumptions(assumptions), + unmitigatedRiskIds: riskHotspots + .filter((risk) => risk.mitigation === undefined) + .map((risk) => risk.id), + riskHotspots, + }; +} diff --git a/src/infrastructure/ObservedGraphProjection.ts b/src/infrastructure/ObservedGraphProjection.ts index ad5e0eaf..b1c9f4b1 100644 --- a/src/infrastructure/ObservedGraphProjection.ts +++ b/src/infrastructure/ObservedGraphProjection.ts @@ -47,10 +47,12 @@ import { import type { ApprovalGateStatus, ApprovalNode, + AssumptionNode, CampaignNode, CampaignStatus, ComparisonArtifactGovernanceDetail, CommentNode, + ConstraintNode, CriterionNode, DecisionNode, EntityDetail, @@ -62,6 +64,7 @@ import type { GovernanceDetail, NarrativeNode, PolicyNode, + RiskNode, QuestDetail, QuestTimelineEntry, QuestNode, @@ -72,6 +75,7 @@ import type { SubmissionNode, SuggestionNode, AiSuggestionNode, + SpikeNode, CollapseProposalGovernanceDetail, AttestationGovernanceDetail, GovernanceArtifactNode, @@ -468,15 +472,16 @@ class UnifiedStateReader { static async create( graph: ObservedProjectionGraph, _profile: SnapshotProfile, - includeFlags: { - includeStoryModels: boolean; - includeRequirementModels: boolean; - includeCriterionModels: boolean; - includeEvidenceModels: boolean; - includePolicyModels: boolean; - includeCaseNodes: boolean; - includeGovernanceArtifacts: boolean; - }, + includeFlags: { + includeStoryModels: boolean; + includeRequirementModels: boolean; + includeCriterionModels: boolean; + includeEvidenceModels: boolean; + includePlanningModels: boolean; + includePolicyModels: boolean; + includeCaseNodes: boolean; + includeGovernanceArtifacts: boolean; + }, ): Promise { let state = null; try { @@ -501,6 +506,7 @@ class UnifiedStateReader { scrollNodes, approvalNodes, submissionNodes, patchsetNodes, reviewNodes, decisionNodes, storyNodes, requirementNodes, criterionNodes, evidenceNodes, policyNodes, + planningConstraintNodes, planningAssumptionNodes, planningRiskNodes, planningSpikeNodes, suggestionNodes, caseNodes, comparisonArtifactNodes, collapseProposalNodes, attestationNodes, @@ -528,6 +534,18 @@ class UnifiedStateReader { includeFlags.includeEvidenceModels ? graph.query().match('evidence:*').select(['id', 'props']).run().then(extractNodes) : Promise.resolve([]), + includeFlags.includePlanningModels + ? graph.query().match('constraint:*').select(['id', 'props']).run().then(extractNodes) + : Promise.resolve([]), + includeFlags.includePlanningModels + ? graph.query().match('assumption:*').select(['id', 'props']).run().then(extractNodes) + : Promise.resolve([]), + includeFlags.includePlanningModels + ? graph.query().match('risk:*').select(['id', 'props']).run().then(extractNodes) + : Promise.resolve([]), + includeFlags.includePlanningModels + ? graph.query().match('spike:*').select(['id', 'props']).run().then(extractNodes) + : Promise.resolve([]), includeFlags.includePolicyModels ? graph.query().match('policy:*').select(['id', 'props']).run().then(extractNodes) : Promise.resolve([]), @@ -553,6 +571,7 @@ class UnifiedStateReader { ...scrollNodes, ...approvalNodes, ...submissionNodes, ...patchsetNodes, ...reviewNodes, ...decisionNodes, ...storyNodes, ...requirementNodes, ...criterionNodes, ...evidenceNodes, ...policyNodes, + ...planningConstraintNodes, ...planningAssumptionNodes, ...planningRiskNodes, ...planningSpikeNodes, ...suggestionNodes, ...caseNodes, ...comparisonArtifactNodes, ...collapseProposalNodes, ...attestationNodes, ]; @@ -599,6 +618,26 @@ class UnifiedStateReader { include = true; needsNeighbor = true; } + } else if (id.startsWith('constraint:')) { + if (includeFlags.includePlanningModels) { + include = true; + needsNeighbor = true; + } + } else if (id.startsWith('assumption:')) { + if (includeFlags.includePlanningModels) { + include = true; + needsNeighbor = true; + } + } else if (id.startsWith('risk:')) { + if (includeFlags.includePlanningModels) { + include = true; + needsNeighbor = true; + } + } else if (id.startsWith('spike:')) { + if (includeFlags.includePlanningModels) { + include = true; + needsNeighbor = true; + } } else if (id.startsWith('case:')) { if (includeFlags.includeCaseNodes) { include = true; @@ -809,6 +848,7 @@ class ObservedGraphProjectionImpl implements ObservedGraphProjection { const includeRequirementModels = includeFullTraceability || includeAnalysisTraceability || includeAuditTraceability; const includeCriterionModels = includeFullTraceability || includeAnalysisTraceability || includeAuditTraceability; const includeEvidenceModels = includeFullTraceability || includeAnalysisTraceability || includeAuditTraceability; + const includePlanningModels = includeFullTraceability || includeAnalysisTraceability || includeAuditTraceability; const includeLegacySuggestions = includeFullTraceability || includeAnalysisTraceability || includeAuditTraceability; const includeStoryModels = includeFullTraceability || includeAuditTraceability; const includePolicyModels = includeFullTraceability || includeAuditTraceability; @@ -856,6 +896,7 @@ class ObservedGraphProjectionImpl implements ObservedGraphProjection { includeRequirementModels, includeCriterionModels, includeEvidenceModels, + includePlanningModels, includePolicyModels, includeCaseNodes, includeGovernanceArtifacts, @@ -865,7 +906,7 @@ class ObservedGraphProjectionImpl implements ObservedGraphProjection { taskNodes, campaignNodes, milestoneNodes, intentNodes, scrollNodes, approvalNodes, submissionNodes, patchsetNodes, reviewNodes, decisionNodes, - storyNodes, requirementNodes, criterionNodes, evidenceNodes, policyNodes, + storyNodes, requirementNodes, criterionNodes, evidenceNodes, planningConstraintNodes, planningAssumptionNodes, planningRiskNodes, planningSpikeNodes, policyNodes, suggestionNodes, caseNodes, comparisonArtifactNodes, collapseProposalNodes, attestationNodes, @@ -884,6 +925,10 @@ class ObservedGraphProjectionImpl implements ObservedGraphProjection { includeRequirementModels ? reader.queryNodesByPrefix('req') : Promise.resolve([]), includeCriterionModels ? reader.queryNodesByPrefix('criterion') : Promise.resolve([]), includeEvidenceModels ? reader.queryNodesByPrefix('evidence') : Promise.resolve([]), + includePlanningModels ? reader.queryNodesByPrefix('constraint') : Promise.resolve([]), + includePlanningModels ? reader.queryNodesByPrefix('assumption') : Promise.resolve([]), + includePlanningModels ? reader.queryNodesByPrefix('risk') : Promise.resolve([]), + includePlanningModels ? reader.queryNodesByPrefix('spike') : Promise.resolve([]), includePolicyModels ? reader.queryNodesByPrefix('policy') : Promise.resolve([]), reader.queryNodesByPrefix('suggestion'), includeCaseNodes ? reader.queryNodesByPrefix('case') : Promise.resolve([]), @@ -908,6 +953,10 @@ class ObservedGraphProjectionImpl implements ObservedGraphProjection { ...(includeStoryModels ? storyNodes.map((n) => n.id) : []), ...(includeRequirementModels ? requirementNodes.map((n) => n.id) : []), ...(includeEvidenceModels ? evidenceNodes.map((n) => n.id) : []), + ...(includePlanningModels ? planningConstraintNodes.map((n) => n.id) : []), + ...(includePlanningModels ? planningAssumptionNodes.map((n) => n.id) : []), + ...(includePlanningModels ? planningRiskNodes.map((n) => n.id) : []), + ...(includePlanningModels ? planningSpikeNodes.map((n) => n.id) : []), ...(includePolicyModels ? policyNodes.map((n) => n.id) : []), ...caseNodes.map((n) => n.id), ]; @@ -1097,6 +1146,10 @@ class ObservedGraphProjectionImpl implements ObservedGraphProjection { const evidence: EvidenceNode[] = []; const criteria: CriterionNode[] = []; const policies: PolicyNode[] = []; + const constraints: ConstraintNode[] = []; + const assumptions: AssumptionNode[] = []; + const risks: RiskNode[] = []; + const spikes: SpikeNode[] = []; if (includeFullTraceability) { log('Building traceability models…'); @@ -1861,6 +1914,94 @@ class ObservedGraphProjectionImpl implements ObservedGraphProjection { } } + if (includePlanningModels) { + log('Building planning qualifier models…'); + + const extractTargetIds = (nodeId: string, labels: ReadonlySet): string[] => { + const neighbors = neighborsCache.get(nodeId) ?? []; + const targetIds = neighbors + .filter((nb) => labels.has(nb.label) && ( + nb.nodeId.startsWith('task:') || + nb.nodeId.startsWith('campaign:') || + nb.nodeId.startsWith('milestone:') || + nb.nodeId.startsWith('req:') || + nb.nodeId.startsWith('story:') || + nb.nodeId.startsWith('assumption:') || + nb.nodeId.startsWith('risk:') + )) + .map((nb) => nb.nodeId); + return [...new Set(targetIds)]; + }; + + for (const n of planningConstraintNodes) { + if (n.props['type'] !== 'constraint') continue; + const description = n.props['description']; + const threshold = n.props['threshold']; + const unit = n.props['unit']; + if (typeof description !== 'string' || typeof threshold !== 'string' || typeof unit !== 'string') continue; + constraints.push({ + id: n.id, + description, + threshold, + unit, + targetIds: extractTargetIds(n.id, new Set(['constrains'])), + }); + } + + for (const n of planningAssumptionNodes) { + if (n.props['type'] !== 'assumption') continue; + const description = n.props['description']; + const validated = n.props['validated']; + const validatedAt = n.props['validated_at']; + if (typeof description !== 'string' || typeof validated !== 'boolean') continue; + assumptions.push({ + id: n.id, + description, + validated, + validatedAt: typeof validatedAt === 'number' ? validatedAt : undefined, + targetIds: extractTargetIds(n.id, new Set(['assumes'])), + }); + } + + for (const n of planningRiskNodes) { + if (n.props['type'] !== 'risk') continue; + const description = n.props['description']; + const likelihood = n.props['likelihood']; + const impact = n.props['impact']; + const mitigation = n.props['mitigation']; + if ( + typeof description !== 'string' || + typeof likelihood !== 'number' || + !Number.isFinite(likelihood) || + typeof impact !== 'number' || + !Number.isFinite(impact) + ) { + continue; + } + risks.push({ + id: n.id, + description, + likelihood, + impact, + mitigation: typeof mitigation === 'string' ? mitigation : undefined, + targetIds: extractTargetIds(n.id, new Set(['threatens'])), + }); + } + + for (const n of planningSpikeNodes) { + if (n.props['type'] !== 'spike') continue; + const timeboxHours = n.props['timebox_hours']; + const outcome = n.props['outcome']; + if (typeof timeboxHours !== 'number' || !Number.isFinite(timeboxHours) || timeboxHours < 0 || typeof outcome !== 'string') continue; + spikes.push({ + id: n.id, + timeboxHours, + outcome, + targetIds: extractTargetIds(n.id, new Set(['informs', 'investigates'])), + }); + } + } + // --- Build suggestions (M11 Phase 4) --- log('Building suggestion models…'); const suggestions: SuggestionNode[] = []; @@ -2104,6 +2245,7 @@ class ObservedGraphProjectionImpl implements ObservedGraphProjection { campaigns, quests, intents, scrolls, approvals, submissions, reviews, decisions, stories, requirements, criteria, evidence, policies, suggestions, aiSuggestions, + constraints, assumptions, risks, spikes, governanceArtifacts, asOf: Date.now(), graphMeta, sortedTaskIds, sortedCampaignIds, transitiveDownstream, diff --git a/src/schema.ts b/src/schema.ts index 0fe17304..71ef61a8 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -9,6 +9,7 @@ export const PREFIXES = [ 'tool', 'event', 'metric', 'artifact', 'roadmap', 'campaign', 'intent', 'approval', 'submission', 'patchset', 'review', 'story', 'req', 'criterion', 'evidence', + 'constraint', 'assumption', 'risk', 'spike', 'policy', 'config', 'suggestion', 'case', 'brief', 'proposal', 'attestation', 'comparison-artifact', 'collapse-proposal', 'invariant', 'legend', 'cycle', 'design', 'retro', 'bearing', @@ -24,6 +25,7 @@ export const EDGE_TYPES = [ 'proposes', 'targets', 'attests', 'submits', 'has-patchset', 'supersedes', 'reviews', 'decides', 'decomposes-to', 'has-criterion', 'verifies', + 'constrains', 'assumes', 'threatens', 'informs', 'investigates', 'governs', 'suggests', 'opened-from', 'concerns', 'briefs', 'causes', 'protects', 'contains', 'closes', diff --git a/src/tui/render-status.ts b/src/tui/render-status.ts index 50733525..63989e50 100644 --- a/src/tui/render-status.ts +++ b/src/tui/render-status.ts @@ -7,18 +7,26 @@ import { getDefaultContext, surfaceToString, type Surface, } from '@flyingrobots/bijou'; import type { + AssumptionNode, CriterionNode, ComputedCompletionSummary, + ConstraintNode, EvidenceNode, GraphSnapshot, PolicyNode, + RiskNode, RequirementNode, StoryNode, SuggestionNode, AiSuggestionNode, + SpikeNode, } from '../domain/models/dashboard.js'; import type { BlockerInfo } from '../domain/services/DepAnalysis.js'; -import type { UnmetRequirement, CoverageResult } from '../domain/services/TraceabilityAnalysis.js'; +import type { + UnmetRequirement, + CoverageResult, + PlanningGapSummary, +} from '../domain/services/TraceabilityAnalysis.js'; import type { StylePort } from '../ports/StylePort.js'; import { statusVariant, sliceDate, groupBy } from './view-helpers.js'; @@ -794,10 +802,15 @@ export interface TraceViewData { criteria: CriterionNode[]; evidence: EvidenceNode[]; policies: PolicyNode[]; + constraints: ConstraintNode[]; + assumptions: AssumptionNode[]; + risks: RiskNode[]; + spikes: SpikeNode[]; unmetRequirements: UnmetRequirement[]; untestedCriteria: string[]; failingCriteria: string[]; coverage: CoverageResult; + planningGaps: PlanningGapSummary; questCompletion: { id: string; title: string; @@ -838,7 +851,7 @@ export function renderTrace(data: TraceViewData, style: StylePort): string { lines.push(snapshotHeader(style, 'Traceability', - `${data.stories.length} stories ${data.requirements.length} reqs ${data.criteria.length} criteria ${data.policies.length} policies coverage: ${pct}`, + `${data.stories.length} stories ${data.requirements.length} reqs ${data.criteria.length} criteria ${data.policies.length} policies ${data.constraints.length} constraints ${data.assumptions.length} assumptions ${data.risks.length} risks ${data.spikes.length} spikes coverage: ${pct}`, 'secondary', )); @@ -964,6 +977,148 @@ export function renderTrace(data: TraceViewData, style: StylePort): string { })); } + if (data.constraints.length > 0) { + lines.push(''); + lines.push(separator({ label: 'Constraints', borderToken: style.theme.border.secondary })); + const rows = data.constraints.map((constraint) => [ + style.styled(style.theme.semantic.muted, constraint.id.slice(0, 20)), + constraint.description.slice(0, 34), + constraint.threshold.slice(0, 18), + constraint.unit.slice(0, 12), + String(constraint.targetIds.length), + ]); + lines.push(table({ + columns: [ + { header: 'Constraint', width: 22 }, + { header: 'Description', width: 36 }, + { header: 'Threshold', width: 20 }, + { header: 'Unit', width: 14 }, + { header: 'Targets', width: 8 }, + ], + rows, + headerToken: style.theme.ui.tableHeader, + borderToken: style.theme.border.primary, + })); + } + + if (data.assumptions.length > 0) { + lines.push(''); + lines.push(separator({ label: 'Assumptions', borderToken: style.theme.border.secondary })); + const rows = data.assumptions.map((assumption) => [ + style.styled(style.theme.semantic.muted, assumption.id.slice(0, 20)), + assumption.description.slice(0, 34), + badge(assumption.validated ? 'validated' : 'open', { variant: assumption.validated ? 'success' : 'warning' }), + assumption.validatedAt ? sliceDate(assumption.validatedAt) : '—', + String(assumption.targetIds.length), + ]); + lines.push(table({ + columns: [ + { header: 'Assumption', width: 22 }, + { header: 'Description', width: 36 }, + { header: 'State', width: 12 }, + { header: 'Validated At', width: 16 }, + { header: 'Targets', width: 8 }, + ], + rows, + headerToken: style.theme.ui.tableHeader, + borderToken: style.theme.border.primary, + })); + } + + if (data.risks.length > 0) { + lines.push(''); + lines.push(separator({ label: 'Risks', borderToken: style.theme.border.secondary })); + const rows = data.risks.map((risk) => [ + style.styled(style.theme.semantic.muted, risk.id.slice(0, 20)), + risk.description.slice(0, 34), + risk.likelihood.toFixed(2), + risk.impact.toFixed(2), + Math.round(risk.likelihood * risk.impact * 100).toString(), + risk.mitigation?.slice(0, 20) ?? '—', + ]); + lines.push(table({ + columns: [ + { header: 'Risk', width: 22 }, + { header: 'Description', width: 36 }, + { header: 'Likely', width: 8 }, + { header: 'Impact', width: 8 }, + { header: 'Score', width: 8 }, + { header: 'Mitigation', width: 22 }, + ], + rows, + headerToken: style.theme.ui.tableHeader, + borderToken: style.theme.border.primary, + })); + } + + if (data.spikes.length > 0) { + lines.push(''); + lines.push(separator({ label: 'Spikes', borderToken: style.theme.border.secondary })); + const rows = data.spikes.map((spike) => [ + style.styled(style.theme.semantic.muted, spike.id.slice(0, 20)), + `${spike.timeboxHours}h`, + spike.outcome.slice(0, 40), + String(spike.targetIds.length), + ]); + lines.push(table({ + columns: [ + { header: 'Spike', width: 22 }, + { header: 'Timebox', width: 10 }, + { header: 'Outcome', width: 44 }, + { header: 'Targets', width: 8 }, + ], + rows, + headerToken: style.theme.ui.tableHeader, + borderToken: style.theme.border.primary, + })); + } + + if ( + data.planningGaps.unvalidatedAssumptionIds.length > 0 || + data.planningGaps.unmitigatedRiskIds.length > 0 || + data.planningGaps.riskHotspots.length > 0 + ) { + lines.push(''); + lines.push(separator({ label: 'Planning Gaps', borderToken: style.theme.border.warning })); + + if (data.planningGaps.unvalidatedAssumptionIds.length > 0) { + const items = data.planningGaps.unvalidatedAssumptionIds.map((id) => { + const assumption = data.assumptions.find((entry) => entry.id === id); + return `${id} ${assumption?.description.slice(0, 48) ?? '—'}`; + }); + lines.push(style.styled(style.theme.semantic.warning, ' Unvalidated assumptions')); + lines.push(enumeratedList(items, { style: 'arabic', indent: 6 })); + } + + if (data.planningGaps.unmitigatedRiskIds.length > 0) { + const items = data.planningGaps.unmitigatedRiskIds.map((id) => { + const risk = data.risks.find((entry) => entry.id === id); + return `${id} ${risk?.description.slice(0, 48) ?? '—'}`; + }); + lines.push(style.styled(style.theme.semantic.warning, ' Unmitigated risks')); + lines.push(enumeratedList(items, { style: 'arabic', indent: 6 })); + } + + if (data.planningGaps.riskHotspots.length > 0) { + const rows = data.planningGaps.riskHotspots.slice(0, 5).map((risk) => [ + style.styled(style.theme.semantic.muted, risk.id.slice(0, 20)), + risk.score.toFixed(2), + risk.mitigation?.slice(0, 20) ?? '—', + ]); + lines.push(style.styled(style.theme.semantic.warning, ' Risk hotspots')); + lines.push(table({ + columns: [ + { header: 'Risk', width: 22 }, + { header: 'Score', width: 8 }, + { header: 'Mitigation', width: 22 }, + ], + rows, + headerToken: style.theme.ui.tableHeader, + borderToken: style.theme.border.primary, + })); + } + } + if (data.questCompletion.length > 0) { lines.push(''); lines.push(separator({ label: 'Quest Completion', borderToken: style.theme.border.secondary })); @@ -1041,12 +1196,18 @@ export function renderTrace(data: TraceViewData, style: StylePort): string { lines.push(` ${style.styled(style.theme.semantic.muted, 'Requirements:')} ${data.requirements.length}`); lines.push(` ${style.styled(style.theme.semantic.muted, 'Criteria:')} ${data.criteria.length}`); lines.push(` ${style.styled(style.theme.semantic.muted, 'Policies:')} ${data.policies.length}`); + lines.push(` ${style.styled(style.theme.semantic.muted, 'Constraints:')} ${data.constraints.length}`); + lines.push(` ${style.styled(style.theme.semantic.muted, 'Assumptions:')} ${data.assumptions.length}`); + lines.push(` ${style.styled(style.theme.semantic.muted, 'Risks:')} ${data.risks.length}`); + lines.push(` ${style.styled(style.theme.semantic.muted, 'Spikes:')} ${data.spikes.length}`); lines.push(` ${style.styled(style.theme.semantic.muted, 'Observed:')} ${data.coverage.evidenced} / ${data.coverage.total}`); lines.push(` ${style.styled(style.theme.semantic.muted, 'Satisfied:')} ${data.coverage.satisfied}`); lines.push(` ${style.styled(style.theme.semantic.muted, 'Failing:')} ${data.coverage.failing}`); 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, 'Unvalidated assumptions:')} ${data.planningGaps.unvalidatedAssumptionIds.length}`); + lines.push(` ${style.styled(style.theme.semantic.muted, 'Unmitigated risks:')} ${data.planningGaps.unmitigatedRiskIds.length}`); 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}`); diff --git a/test/helpers/observation.ts b/test/helpers/observation.ts index 593e4219..fe0e4562 100644 --- a/test/helpers/observation.ts +++ b/test/helpers/observation.ts @@ -149,6 +149,70 @@ export function makeObservationSessionDouble( }); const suggestionNodes = snapshot.aiSuggestions.map(toSuggestionNode); + const planningNodes = [ + ...snapshot.constraints.map((constraint) => ({ + id: constraint.id, + props: { + type: 'constraint', + description: constraint.description, + threshold: constraint.threshold, + unit: constraint.unit, + }, + })), + ...snapshot.assumptions.map((assumption) => ({ + id: assumption.id, + props: { + type: 'assumption', + description: assumption.description, + validated: assumption.validated, + validated_at: assumption.validatedAt, + }, + })), + ...snapshot.risks.map((risk) => ({ + id: risk.id, + props: { + type: 'risk', + description: risk.description, + likelihood: risk.likelihood, + impact: risk.impact, + mitigation: risk.mitigation, + }, + })), + ...snapshot.spikes.map((spike) => ({ + id: spike.id, + props: { + type: 'spike', + timebox_hours: spike.timeboxHours, + outcome: spike.outcome, + }, + })), + ]; + for (const constraint of snapshot.constraints) { + generatedOutgoing[constraint.id] = constraint.targetIds.map((targetId) => ({ + nodeId: targetId, + label: 'constrains' as const, + })); + } + for (const assumption of snapshot.assumptions) { + generatedOutgoing[assumption.id] = assumption.targetIds.map((targetId) => ({ + nodeId: targetId, + label: 'assumes' as const, + })); + } + for (const risk of snapshot.risks) { + generatedOutgoing[risk.id] = risk.targetIds.map((targetId) => ({ + nodeId: targetId, + label: 'threatens' as const, + })); + } + for (const spike of snapshot.spikes) { + generatedOutgoing[spike.id] = spike.targetIds.map((targetId) => ({ + nodeId: targetId, + label: targetId.startsWith('risk:') || targetId.startsWith('assumption:') + ? 'investigates' as const + : 'informs' as const, + })); + } const fetchEntityDetail = opts?.fetchEntityDetail ?? vi.fn(); const outgoing = { @@ -162,6 +226,10 @@ export function makeObservationSessionDouble( 'review:*': reviewNodes, 'decision:*': decisionNodes, 'suggestion:*': suggestionNodes, + 'constraint:*': planningNodes.filter((node) => node.props.type === 'constraint'), + 'assumption:*': planningNodes.filter((node) => node.props.type === 'assumption'), + 'risk:*': planningNodes.filter((node) => node.props.type === 'risk'), + 'spike:*': planningNodes.filter((node) => node.props.type === 'spike'), ...(opts?.extraNodesByPattern ?? {}), }; diff --git a/test/helpers/snapshot.ts b/test/helpers/snapshot.ts index 09229588..2ad7e83a 100644 --- a/test/helpers/snapshot.ts +++ b/test/helpers/snapshot.ts @@ -37,6 +37,10 @@ export function makeSnapshot(overrides?: Partial): GraphSnapshot criteria: [], evidence: [], policies: [], + constraints: [], + assumptions: [], + risks: [], + spikes: [], suggestions: [], aiSuggestions: [], governanceArtifacts: [], diff --git a/test/unit/AgentActionService.test.ts b/test/unit/AgentActionService.test.ts index cab8722a..45b86147 100644 --- a/test/unit/AgentActionService.test.ts +++ b/test/unit/AgentActionService.test.ts @@ -184,11 +184,11 @@ function makeDoctorReport(overrides?: Partial>) { asOf: 1, graphMeta: null, auditedStatuses: ['PLANNED', 'READY'], - counts: { - campaigns: 0, - quests: 0, - intents: 0, - scrolls: 0, + counts: { + campaigns: 0, + quests: 0, + intents: 0, + scrolls: 0, approvals: 0, submissions: 0, patchsets: 0, @@ -196,13 +196,17 @@ function makeDoctorReport(overrides?: Partial>) { decisions: 0, stories: 0, requirements: 0, - criteria: 0, - evidence: 0, - policies: 0, - suggestions: 0, - documents: 0, - comments: 0, - }, + criteria: 0, + evidence: 0, + policies: 0, + constraints: 0, + assumptions: 0, + risks: 0, + spikes: 0, + suggestions: 0, + documents: 0, + comments: 0, + }, summary: { issueCount: 0, blockingIssueCount: 0, diff --git a/test/unit/AgentBriefingService.test.ts b/test/unit/AgentBriefingService.test.ts index f1063be1..ea4b3832 100644 --- a/test/unit/AgentBriefingService.test.ts +++ b/test/unit/AgentBriefingService.test.ts @@ -103,6 +103,10 @@ function makeDoctor( criteria: 0, evidence: 0, policies: 0, + constraints: 0, + assumptions: 0, + risks: 0, + spikes: 0, suggestions: 0, documents: 0, comments: 0, diff --git a/test/unit/AgentContextService.test.ts b/test/unit/AgentContextService.test.ts index 48a12f4e..3db4d063 100644 --- a/test/unit/AgentContextService.test.ts +++ b/test/unit/AgentContextService.test.ts @@ -68,6 +68,10 @@ function makeDoctor() { criteria: 0, evidence: 0, policies: 0, + constraints: 0, + assumptions: 0, + risks: 0, + spikes: 0, suggestions: 0, documents: 0, comments: 0, diff --git a/test/unit/DashboardTraceCommand.test.ts b/test/unit/DashboardTraceCommand.test.ts index 90f5d24f..d6b21f26 100644 --- a/test/unit/DashboardTraceCommand.test.ts +++ b/test/unit/DashboardTraceCommand.test.ts @@ -72,6 +72,10 @@ describe('dashboard trace view JSON', () => { criteria: 0, evidence: 0, policies: 0, + constraints: 0, + assumptions: 0, + risks: 0, + spikes: 0, suggestions: 0, documents: 0, comments: 0, @@ -137,6 +141,32 @@ describe('dashboard trace view JSON', () => { requireEvidence: true, allowManualSeal: false, }], + constraints: [{ + id: 'constraint:TRACE', + description: 'Traceability needs a capped scope', + threshold: '4 tasks', + unit: 'scope', + targetIds: ['req:TRACE'], + }], + assumptions: [{ + id: 'assumption:TRACE', + description: 'Traceability packet remains stable', + validated: false, + targetIds: ['task:TRACE'], + }], + risks: [{ + id: 'risk:TRACE', + description: 'Traceability gaps can stall completion', + likelihood: 0.8, + impact: 0.5, + targetIds: ['req:TRACE'], + }], + spikes: [{ + id: 'spike:TRACE', + timeboxHours: 3, + outcome: 'Validated the missing links', + targetIds: ['risk:TRACE', 'assumption:TRACE'], + }], }); fetchSnapshot.mockResolvedValue(snapshot); @@ -175,17 +205,27 @@ describe('dashboard trace view JSON', () => { criteria: snapshot.criteria, evidence: snapshot.evidence, policies: snapshot.policies, + constraints: snapshot.constraints, + assumptions: snapshot.assumptions, + risks: snapshot.risks, + spikes: snapshot.spikes, summary: { stories: 1, requirements: 1, criteria: 1, policies: 1, + constraints: 1, + assumptions: 1, + risks: 1, + spikes: 1, evidenced: 1, satisfied: 1, failing: 0, linkedOnly: 0, unevidenced: 0, coverageRatio: 1, + unvalidatedAssumptions: 1, + unmitigatedRisks: 1, computedCompleteQuests: 0, computedTrackedQuests: 0, computedCompleteCampaigns: 0, @@ -196,6 +236,17 @@ describe('dashboard trace view JSON', () => { unmetRequirements: [], untestedCriteria: [], failingCriteria: [], + planningGaps: { + unvalidatedAssumptionIds: ['assumption:TRACE'], + unmitigatedRiskIds: ['risk:TRACE'], + riskHotspots: [{ + id: 'risk:TRACE', + likelihood: 0.8, + impact: 0.5, + score: 0.4, + targetIds: ['req:TRACE'], + }], + }, questCompletion: [], campaignCompletion: [], questDiscrepancies: [], diff --git a/test/unit/DiagnosticService.test.ts b/test/unit/DiagnosticService.test.ts index aadc4fb9..aef92d87 100644 --- a/test/unit/DiagnosticService.test.ts +++ b/test/unit/DiagnosticService.test.ts @@ -15,11 +15,11 @@ function makeDoctorReport(): DoctorReport { asOf: 1, graphMeta: null, auditedStatuses: ['PLANNED', 'READY'], - counts: { - campaigns: 1, - quests: 2, - intents: 1, - scrolls: 0, + counts: { + campaigns: 1, + quests: 2, + intents: 1, + scrolls: 0, approvals: 0, submissions: 0, patchsets: 0, @@ -27,13 +27,17 @@ function makeDoctorReport(): DoctorReport { decisions: 0, stories: 0, requirements: 0, - criteria: 0, - evidence: 0, - policies: 1, - suggestions: 0, - documents: 0, - comments: 0, - }, + criteria: 0, + evidence: 0, + policies: 1, + constraints: 0, + assumptions: 0, + risks: 0, + spikes: 0, + suggestions: 0, + documents: 0, + comments: 0, + }, summary: { issueCount: 4, blockingIssueCount: 1, diff --git a/test/unit/DoctorCommands.test.ts b/test/unit/DoctorCommands.test.ts index 94de8672..e71e2d04 100644 --- a/test/unit/DoctorCommands.test.ts +++ b/test/unit/DoctorCommands.test.ts @@ -76,6 +76,10 @@ describe('doctor command', () => { criteria: 0, evidence: 0, policies: 0, + constraints: 0, + assumptions: 0, + risks: 0, + spikes: 0, suggestions: 0, documents: 0, comments: 0, diff --git a/test/unit/TraceabilityAnalysis.test.ts b/test/unit/TraceabilityAnalysis.test.ts index f805acd6..4166d657 100644 --- a/test/unit/TraceabilityAnalysis.test.ts +++ b/test/unit/TraceabilityAnalysis.test.ts @@ -6,8 +6,11 @@ import { computeCoverageRatio, computeCompletionSummary, computeCriterionVerdicts, + computePlanningGapSummary, type RequirementSummary, type CriterionSummary, + type AssumptionSummary, + type RiskSummary, } from '../../src/domain/services/TraceabilityAnalysis.js'; describe('computeUnmetRequirements', () => { @@ -308,6 +311,61 @@ describe('computeCompletionSummary', () => { }); }); +describe('computePlanningGapSummary', () => { + it('finds unvalidated assumptions and risk hotspots', () => { + const assumptions: AssumptionSummary[] = [ + { + id: 'assumption:A', + validated: true, + validatedAt: 100, + targetIds: ['req:A'], + }, + { + id: 'assumption:B', + validated: false, + targetIds: ['task:B'], + }, + ]; + const risks: RiskSummary[] = [ + { + id: 'risk:A', + likelihood: 0.8, + impact: 0.5, + mitigation: 'Add a retry budget', + targetIds: ['req:A'], + }, + { + id: 'risk:B', + likelihood: 0.9, + impact: 0.9, + targetIds: ['task:B'], + }, + ]; + + expect(computePlanningGapSummary(assumptions, risks)).toEqual({ + unvalidatedAssumptionIds: ['assumption:B'], + unmitigatedRiskIds: ['risk:B'], + riskHotspots: [ + { + id: 'risk:B', + likelihood: 0.9, + impact: 0.9, + score: 0.81, + targetIds: ['task:B'], + }, + { + id: 'risk:A', + likelihood: 0.8, + impact: 0.5, + score: 0.4, + targetIds: ['req:A'], + mitigation: 'Add a retry budget', + }, + ], + }); + }); +}); + describe('computeCriterionVerdicts', () => { it('prefers the latest fail over earlier passes and linked observations', () => { const criteria: CriterionSummary[] = [ diff --git a/test/unit/TraceabilityCommands.test.ts b/test/unit/TraceabilityCommands.test.ts index 167ad9d3..ddc04659 100644 --- a/test/unit/TraceabilityCommands.test.ts +++ b/test/unit/TraceabilityCommands.test.ts @@ -200,6 +200,201 @@ describe('traceability policy commands', () => { }); }); + it('creates a constraint node with constrains edges', async () => { + const patchBuilder = createPatchBuilder(); + const graph = { + hasNode: vi.fn().mockResolvedValue(true), + patch: vi.fn(async (fn: (builder: typeof patchBuilder) => void) => { + fn(patchBuilder); + return 'patch:constraint'; + }), + }; + const ctx = makeCtx(graph); + const program = new Command(); + registerTraceabilityCommands(program, ctx); + + await program.parseAsync( + [ + 'constraint', + 'constraint:TRACE', + '--description', + 'Builds must stay within a tight memory budget', + '--threshold', + '512MB', + '--unit', + 'memory', + '--requirement', + 'req:TRACE', + '--campaign', + 'campaign:TRACE', + ], + { from: 'user' }, + ); + + expect(patchBuilder.addNode).toHaveBeenCalledWith('constraint:TRACE'); + expect(patchBuilder.addEdge).toHaveBeenCalledWith('constraint:TRACE', 'req:TRACE', 'constrains'); + expect(patchBuilder.addEdge).toHaveBeenCalledWith('constraint:TRACE', 'campaign:TRACE', 'constrains'); + expect(ctx.jsonOut).toHaveBeenCalledWith({ + success: true, + command: 'constraint', + data: { + id: 'constraint:TRACE', + description: 'Builds must stay within a tight memory budget', + threshold: '512MB', + unit: 'memory', + requirement: 'req:TRACE', + campaign: 'campaign:TRACE', + patch: 'patch:constraint', + }, + }); + }); + + it('creates an assumption node with an optional validation timestamp', async () => { + const patchBuilder = createPatchBuilder(); + const graph = { + hasNode: vi.fn().mockResolvedValue(true), + patch: vi.fn(async (fn: (builder: typeof patchBuilder) => void) => { + fn(patchBuilder); + return 'patch:assumption'; + }), + }; + const ctx = makeCtx(graph); + const program = new Command(); + registerTraceabilityCommands(program, ctx); + + await program.parseAsync( + [ + 'assumption', + 'assumption:TRACE', + '--description', + 'The cache remains warm during the demo', + '--validated', + '--validated-at', + '1700000000001', + '--task', + 'task:TRACE', + ], + { from: 'user' }, + ); + + expect(patchBuilder.addNode).toHaveBeenCalledWith('assumption:TRACE'); + expect(patchBuilder.setProperty).toHaveBeenCalledWith('assumption:TRACE', 'validated_at', 1700000000001); + expect(patchBuilder.addEdge).toHaveBeenCalledWith('assumption:TRACE', 'task:TRACE', 'assumes'); + expect(ctx.jsonOut).toHaveBeenCalledWith({ + success: true, + command: 'assumption', + data: { + id: 'assumption:TRACE', + description: 'The cache remains warm during the demo', + validated: true, + validatedAt: 1700000000001, + task: 'task:TRACE', + requirement: null, + patch: 'patch:assumption', + }, + }); + }); + + it('creates a risk node with threatens edges and mitigation', async () => { + const patchBuilder = createPatchBuilder(); + const graph = { + hasNode: vi.fn().mockResolvedValue(true), + patch: vi.fn(async (fn: (builder: typeof patchBuilder) => void) => { + fn(patchBuilder); + return 'patch:risk'; + }), + }; + const ctx = makeCtx(graph); + const program = new Command(); + registerTraceabilityCommands(program, ctx); + + await program.parseAsync( + [ + 'risk', + 'risk:TRACE', + '--description', + 'The deployment path may stall under load', + '--likelihood', + '0.75', + '--impact', + '0.9', + '--mitigation', + 'Add queue backpressure', + '--requirement', + 'req:TRACE', + ], + { from: 'user' }, + ); + + expect(patchBuilder.addNode).toHaveBeenCalledWith('risk:TRACE'); + expect(patchBuilder.setProperty).toHaveBeenCalledWith('risk:TRACE', 'mitigation', 'Add queue backpressure'); + expect(patchBuilder.addEdge).toHaveBeenCalledWith('risk:TRACE', 'req:TRACE', 'threatens'); + expect(ctx.jsonOut).toHaveBeenCalledWith({ + success: true, + command: 'risk', + data: { + id: 'risk:TRACE', + description: 'The deployment path may stall under load', + likelihood: 0.75, + impact: 0.9, + mitigation: 'Add queue backpressure', + task: null, + requirement: 'req:TRACE', + patch: 'patch:risk', + }, + }); + }); + + it('creates a spike node with informs and investigates edges', async () => { + const patchBuilder = createPatchBuilder(); + const graph = { + hasNode: vi.fn().mockResolvedValue(true), + patch: vi.fn(async (fn: (builder: typeof patchBuilder) => void) => { + fn(patchBuilder); + return 'patch:spike'; + }), + }; + const ctx = makeCtx(graph); + const program = new Command(); + registerTraceabilityCommands(program, ctx); + + await program.parseAsync( + [ + 'spike', + 'spike:TRACE', + '--timebox-hours', + '4', + '--outcome', + 'Spike confirmed the missing guard is a real gap', + '--requirement', + 'req:TRACE', + '--risk', + 'risk:TRACE', + '--assumption', + 'assumption:TRACE', + ], + { from: 'user' }, + ); + + expect(patchBuilder.addNode).toHaveBeenCalledWith('spike:TRACE'); + expect(patchBuilder.addEdge).toHaveBeenCalledWith('spike:TRACE', 'req:TRACE', 'informs'); + expect(patchBuilder.addEdge).toHaveBeenCalledWith('spike:TRACE', 'risk:TRACE', 'investigates'); + expect(patchBuilder.addEdge).toHaveBeenCalledWith('spike:TRACE', 'assumption:TRACE', 'investigates'); + expect(ctx.jsonOut).toHaveBeenCalledWith({ + success: true, + command: 'spike', + data: { + id: 'spike:TRACE', + timeboxHours: 4, + outcome: 'Spike confirmed the missing guard is a real gap', + requirement: 'req:TRACE', + risk: 'risk:TRACE', + assumption: 'assumption:TRACE', + patch: 'patch:spike', + }, + }); + }); + it('packet creates a minimal story→req→criterion chain for a quest', async () => { const patchBuilder = createPatchBuilder(); const graph = {