diff --git a/CHANGELOG.md b/CHANGELOG.md index 1253924..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 @@ -14,6 +15,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/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..5841544 --- /dev/null +++ b/docs/canonical/AGENT_PROTOCOL.md @@ -0,0 +1,339 @@ +# 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. **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. + 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 + +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. + +Current runtime tranche: + +- shipped now: `briefing` +- shipped now: `next` +- shipped now: `context ` +- shipped now: `submissions` +- shipped now: `handoff` +- shipped now: `claim`, `shape`, `packet`, `ready`, `comment`, `submit`, `review`, `handoff`, `seal`, `merge` +- shipped now: `act ` for that subset + +### 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. + +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. + +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. + +`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: + +- `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. +If a follow-on step fails after mutation has already been committed, the +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` + +`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 routine agent actions that should be executable through `act` in +the checkpoint-2 kernel. + +`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: + +- `claim` +- `shape` +- `packet` +- `ready` +- `comment` +- `submit` +- `review` +- `handoff` +- `seal` +- `merge` + +### 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 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 e9c456d..168e9b0 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,9 +20,12 @@ - `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`. +- `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/docs/canonical/TRACEABILITY.md b/docs/canonical/TRACEABILITY.md index cead03f..59cc7f5 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 @@ -136,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/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": { diff --git a/src/cli/commands/agent.ts b/src/cli/commands/agent.ts new file mode 100644 index 0000000..ac4b629 --- /dev/null +++ b/src/cli/commands/agent.ts @@ -0,0 +1,653 @@ +import type { Command } from 'commander'; +import type { CliContext } from '../context.js'; +import { createErrorHandler } from '../errorHandler.js'; +import { renderDiagnosticsLines } from '../renderDiagnostics.js'; +import { VALID_QUEST_PRIORITIES, 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'; +import { AgentContextService } from '../../domain/services/AgentContextService.js'; +import type { + AgentActionCandidate, + 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 { Diagnostic } from '../../domain/models/diagnostics.js'; +import type { EntityDetail } from '../../domain/models/dashboard.js'; + +interface ActOptions { + dryRun?: boolean; + description?: string; + taskPriority?: string; + title?: string; + rationale?: string; + artifact?: string; + base?: string; + workspace?: string; + into?: string; + patchset?: 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; + verdict?: string; + 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.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(); + 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(); + 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.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; + if (opts.related !== undefined) { + args['relatedIds'] = opts.related + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + } + return args; +} + +function renderHumanOutcome( + ctx: CliContext, + outcome: AgentActionOutcome, +): void { + 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) { + 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])}`); + } + } +} + +function renderAgentContext( + detail: EntityDetail, + readiness: ReadinessAssessment | null, + dependency: AgentDependencyContext | null, + recommendedActions: AgentActionCandidate[], + diagnostics: Diagnostic[], +): 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(`priority: ${quest.priority ?? 'P3'} 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(...renderDiagnosticsLines(diagnostics)); + + 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'); +} + +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; + 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 }[]; + diagnostics: Diagnostic[]; + 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(` next: ${entry.nextStep.kind} ${entry.nextStep.targetId}`); + } + } + + 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}`); + } + } + } + + 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'); + for (const alert of briefing.alerts) { + lines.push(` - ${alert.severity}: ${alert.message}`); + } + } + + 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}`); + } + + 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'); +} + +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); + + 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', + diagnostics: briefing.diagnostics, + 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 result = await service.next(limit); + + if (ctx.json) { + ctx.jsonOut({ + success: true, + command: 'next', + diagnostics: result.diagnostics, + data: { + candidates: result.candidates, + }, + }); + return; + } + + const lines = [renderNext(result.candidates), ...renderDiagnosticsLines(result.diagnostics)]; + ctx.print(lines.join('\n')); + })); + + 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') + .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', + diagnostics: result.diagnostics, + 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, + diagnostics: result.diagnostics, + }, + }, + }); + return; + } + + ctx.print(renderAgentContext( + result.detail, + result.readiness, + result.dependency, + result.recommendedActions, + result.diagnostics, + )); + })); + + 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 ', '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') + .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') + .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('--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') + .option('--related ', 'Additional related IDs for handoff') + .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' || 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}`); + } + + if (ctx.json) { + ctx.jsonOut({ + success: true, + command: 'act', + data: { ...outcome }, + }); + return; + } + + 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/artifact.ts b/src/cli/commands/artifact.ts index 4638f60..a722f50 100644 --- a/src/cli/commands/artifact.ts +++ b/src/cli/commands/artifact.ts @@ -1,42 +1,26 @@ import type { Command } from 'commander'; import type { CliContext } from '../context.js'; import { createErrorHandler } from '../errorHandler.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.`, - }; -} +import { + allowUnsignedScrollsForSettlement, + formatMissingSettlementKeyMessage, + formatUnsignedScrollOverrideWarning, + missingSettlementKeyData, + UNSIGNED_SCROLLS_OVERRIDE_ENV, +} from '../../domain/services/SettlementKeyPolicy.js'; +import { + assessSettlementGate, + formatSettlementGateFailure, + settlementGateFailureData, +} from '../../domain/services/SettlementGateService.js'; +import { settlementAssessmentToDiagnostics } from '../../domain/services/DiagnosticService.js'; +export { + allowUnsignedScrollsForSettlement, + formatMissingSettlementKeyMessage, + formatUnsignedScrollOverrideWarning, + missingSettlementKeyData, + UNSIGNED_SCROLLS_OVERRIDE_ENV, +}; export function registerArtifactCommands(program: Command, ctx: CliContext): void { const withErrorHandler = createErrorHandler(ctx); @@ -49,10 +33,22 @@ 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), + settlementAssessmentToDiagnostics(assessment), + ); + } + // Guard: warn if a non-terminal submission exists for this quest let openSubWarning: string | undefined; try { 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/dashboard.ts b/src/cli/commands/dashboard.ts index 358d3c1..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, @@ -197,12 +215,47 @@ 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({ - success: true, command: 'status', + success: true, command: 'status', diagnostics, data: { view: 'trace', + health, stories: snapshot.stories, requirements: snapshot.requirements, criteria: snapshot.criteria, @@ -219,17 +272,27 @@ 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; } const { renderTrace } = await import('../../tui/render-status.js'); - ctx.print(renderTrace({ + printWithDiagnostics(renderTrace({ stories: snapshot.stories, requirements: snapshot.requirements, criteria: snapshot.criteria, @@ -239,6 +302,10 @@ export function registerDashboardCommands(program: Command, ctx: CliContext): vo untestedCriteria, failingCriteria, coverage, + questCompletion, + campaignCompletion, + questDiscrepancies, + campaignDiscrepancies, }, ctx.style)); break; } @@ -246,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, @@ -262,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; } @@ -278,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/doctor.ts b/src/cli/commands/doctor.ts new file mode 100644 index 0000000..62c7fcc --- /dev/null +++ b/src/cli/commands/doctor.ts @@ -0,0 +1,81 @@ +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'; +import { renderDiagnosticsLines } from '../renderDiagnostics.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} 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.diagnostics.length === 0) { + lines.push(''); + lines.push('No issues found.'); + return lines.join('\n'); + } + + lines.push(...renderDiagnosticsLines(report.diagnostics)); + + 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, + report.diagnostics, + ); + } + ctx.jsonOut({ + success: true, + command: 'doctor', + data: report as unknown as Record, + diagnostics: report.diagnostics, + }); + 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/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 c14eaee..7d8db17 100644 --- a/src/cli/commands/intake.ts +++ b/src/cli/commands/intake.ts @@ -2,7 +2,13 @@ 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 { const taskKind = raw ?? 'delivery'; @@ -12,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); @@ -22,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(); @@ -39,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); @@ -56,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, @@ -76,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) { @@ -95,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, }, @@ -119,6 +141,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 +151,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 - ')}`); } @@ -167,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); @@ -196,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, }, @@ -205,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 47dd842..1212847 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; @@ -71,7 +74,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(', ')}`); } @@ -103,12 +109,16 @@ 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; 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); @@ -124,7 +134,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 +152,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(''); @@ -151,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'); @@ -211,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); + 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, @@ -236,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/commands/submission.ts b/src/cli/commands/submission.ts index d036bca..d421f19 100644 --- a/src/cli/commands/submission.ts +++ b/src/cli/commands/submission.ts @@ -8,7 +8,13 @@ import { formatMissingSettlementKeyMessage, formatUnsignedScrollOverrideWarning, missingSettlementKeyData, -} from './artifact.js'; +} from '../../domain/services/SettlementKeyPolicy.js'; +import { + assessSettlementGate, + 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); @@ -36,7 +42,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 } @@ -103,7 +109,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 } @@ -191,6 +197,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 +208,22 @@ 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), + }, + settlementAssessmentToDiagnostics(assessment), + ); + } + } + if (shouldAutoSeal && !sealService.hasPrivateKey(ctx.agentId) && !allowUnsignedScrolls) { return ctx.failWithData( formatMissingSettlementKeyMessage(ctx.agentId, 'merge'), @@ -208,25 +231,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/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/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/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/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/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/models/dashboard.ts b/src/domain/models/dashboard.ts index 8c46184..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'; @@ -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 { @@ -28,6 +48,7 @@ export interface QuestNode { title: string; status: QuestStatus; hours: number; + priority?: QuestPriority; description?: string; taskKind?: QuestKind; campaignId?: string; @@ -50,6 +71,7 @@ export interface QuestNode { reopenedAt?: number; // Task dependencies (Weaver) dependsOn?: string[]; + computedCompletion?: ComputedCompletionSummary; } export interface IntentNode { @@ -188,6 +210,7 @@ export interface NarrativeNode { title: string; authoredBy: string; authoredAt: number; + noteKind?: string; body?: string; contentOid?: string; targetIds: string[]; 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/AgentActionService.ts b/src/domain/services/AgentActionService.ts new file mode 100644 index 0000000..400d742 --- /dev/null +++ b/src/domain/services/AgentActionService.ts @@ -0,0 +1,1828 @@ +import { randomUUID } from 'node:crypto'; +import type { GraphPort } from '../../ports/GraphPort.js'; +import type { RoadmapQueryPort } from '../../ports/RoadmapPort.js'; +import { + VALID_QUEST_PRIORITIES, + VALID_TASK_KINDS, + type QuestKind, + type QuestPriority, +} 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 { 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', 'seal', 'merge', +] 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' | 'partial-failure' | '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; + priority?: QuestPriority; +} + +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; +} + +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; +} + +interface HandoffAction { + kind: 'handoff'; + targetId: string; + noteId: string; + title: string; + message: string; + relatedIds: string[]; +} + +interface SealAction { + kind: 'seal'; + targetId: string; + artifactHash: string; + rationale: string; +} + +interface MergeAction { + kind: 'merge'; + targetId: string; + rationale: string; + intoRef: string; + tipPatchsetId: string; + mergeRef: string; + workspaceRef?: string; + explicitPatchsetId?: string; + questId?: string; + shouldAutoSeal: boolean; +} + +type SupportedNormalizedAction = + | ClaimAction + | ShapeAction + | PacketAction + | ReadyAction + | CommentAction + | SubmitAction + | ReviewAction + | HandoffAction + | SealAction + | MergeAction; + +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)}`; +} + +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; + private readonly submissions: SubmissionService; + + constructor( + private readonly graphPort: GraphPort, + private readonly roadmap: RoadmapQueryPort, + private readonly agentId: string, + ) { + this.intake = new IntakeService(roadmap); + this.readiness = new ReadinessService(roadmap); + this.submissions = new SubmissionService( + new WarpSubmissionAdapter(graphPort, agentId), + ); + } + + 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); + case 'submit': + return this.validateSubmit(request); + case 'review': + return this.validateReview(request); + case 'handoff': + return this.validateHandoff(request); + case 'seal': + return this.validateSeal(request); + case 'merge': + return this.validateMerge(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}`, + ]); + } + 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, + { 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; + const taskPriorityRaw = request.args['taskPriority']; + const taskPriority = typeof taskPriorityRaw === 'string' ? taskPriorityRaw.trim() : undefined; + + if (descriptionRaw === undefined && taskKind === undefined && taskPriority === undefined) { + return failAssessment(request, 'invalid-args', [ + 'shape requires description, taskKind, and/or taskPriority', + ]); + } + 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(', ')}`, + ]); + } + 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); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return failAssessment(request, 'precondition-failed', [msg], { + 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'] : []), + ], + }); + } + + return successAssessment( + request, + { + kind: 'shape', + 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'] : []), + ], + ); + } + + 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', + ], + ); + } + + 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, workspaceRef); + } 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}`, + ], + ); + } + + 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', + ], + ); + } + + 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', + ], + }); + } + 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, + { + kind: 'merge', + targetId: request.targetId, + rationale, + intoRef, + tipPatchsetId, + mergeRef, + workspaceRef, + explicitPatchsetId, + questId, + shouldAutoSeal, + }, + { + rationale, + intoRef, + patchsetId: explicitPatchsetId ?? null, + tipPatchsetId, + mergeRef, + questId: questId ?? null, + shouldAutoSeal, + workspaceRef, + }, + `xyph merge ${request.targetId}`, + [ + `merge ${mergeRef} into ${intoRef}`, + 'create merge decision', + ...(shouldAutoSeal ? ['auto-seal quest'] : []), + ], + ); + } +} + +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); + case 'submit': + return await this.executeSubmit(assessment, normalized); + case 'review': + 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); + 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 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', now); + }); + + const props = await graph.getNodeProps(action.targetId); + const confirmed = !!( + props && + props['assigned_to'] === this.agentId && + props['claimed_at'] === now + ); + 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', + claimedAt: now, + }, + }; + } + + 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, + priority: action.priority, + }); + 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, + priority: typeof props?.['priority'] === 'string' ? props['priority'] : 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, + }, + }; + } + + 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, + }, + }; + } + + 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, + }, + }; + } + + private async executeSeal( + assessment: ValidatedAssessment, + action: SealAction, + ): Promise { + const keyring = new FsKeyringAdapter(); + const sealService = new GuildSealService(keyring); + const allowUnsignedScrolls = allowUnsignedScrollsForSettlement(); + + 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 (!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 workspace = new GitWorkspaceAdapter(process.cwd()); + let mergeCommit: string | undefined; + const alreadyMerged = await workspace.isMerged(action.mergeRef, action.intoRef); + if (alreadyMerged) { + mergeCommit = action.mergeRef; + } else { + 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 { + 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: 'partial-failure', + 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) { + 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); + } + } 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, + result: 'success', + patch: patchSha, + details: { + submissionId: action.targetId, + decisionId, + questId: action.questId ?? null, + mergeCommit: mergeCommit ?? null, + alreadyMerged, + autoSealed, + guildSeal: guildSealInfo, + warnings, + partialFailure, + }, + }; + } +} diff --git a/src/domain/services/AgentBriefingService.ts b/src/domain/services/AgentBriefingService.ts new file mode 100644 index 0000000..6e1dcd1 --- /dev/null +++ b/src/domain/services/AgentBriefingService.ts @@ -0,0 +1,500 @@ +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, + type AgentSubmissionEntry, + type AgentSubmissionNextStep, +} from './AgentSubmissionService.js'; +import { + AgentRecommender, + type AgentActionCandidate, + type AgentDependencyContext, + type AgentQuestRef, +} from './AgentRecommender.js'; +import { + buildAgentDependencyContext, + 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'; +} + +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; + nextStep: AgentSubmissionNextStep; +} + +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[]; + diagnostics: Diagnostic[]; + graphMeta: GraphMeta | null; +} + +export interface AgentNextCandidate extends AgentActionCandidate { + questTitle: string; + questStatus: string; + source: 'assignment' | 'frontier' | 'planning' | 'submission'; +} + +export interface AgentNextResult { + candidates: AgentNextCandidate[]; + diagnostics: Diagnostic[]; +} + +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 'merge': + return 0; + case 'review': + return 1; + 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; + 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, + ); + } + + 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 recentHandoffs = await this.buildRecentHandoffs(); + const doctorReport = await this.doctor.run(); + const diagnostics = summarizeDoctorReport(doctorReport); + const alerts = this.buildAlerts(assignments, frontier, reviewQueue, diagnostics); + + return { + identity: { + agentId: this.agentId, + principalType: this.agentId.startsWith('human.') ? 'human' : 'agent', + }, + assignments, + reviewQueue, + frontier, + recentHandoffs, + alerts, + diagnostics, + 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.push(...this.buildSubmissionCandidates(snapshot)); + + candidates.sort((a, b) => + sourcePriority(a.source) - sourcePriority(b.source) || + Number(b.allowed) - Number(a.allowed) || + kindPriority(a.kind) - kindPriority(b.kind) || + b.confidence - a.confidence || + a.targetId.localeCompare(b.targetId) + ); + + const doctorReport = await this.doctor.run(); + return { + candidates: candidates.slice(0, limit), + diagnostics: summarizeDoctorReport(doctorReport), + }; + } + + 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) => + isReviewableByAgent(submission, 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: 'Open submission awaiting review.', + nextStep: determineSubmissionNextStep(submission, this.agentId), + } satisfies AgentReviewQueueEntry; + }); + + queue.sort((a, b) => b.submittedAt - a.submittedAt || a.submissionId.localeCompare(b.submissionId)); + 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[], + 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({ + 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; + } + + 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/domain/services/AgentContextService.ts b/src/domain/services/AgentContextService.ts new file mode 100644 index 0000000..b348713 --- /dev/null +++ b/src/domain/services/AgentContextService.ts @@ -0,0 +1,227 @@ +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'; +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'; +import { + AgentRecommender, + type AgentActionCandidate, + type AgentDependencyContext, + type AgentQuestRef, +} from './AgentRecommender.js'; + +export interface AgentContextResult { + detail: EntityDetail; + readiness: ReadinessAssessment | null; + dependency: AgentDependencyContext | null; + recommendedActions: AgentActionCandidate[]; + diagnostics: Diagnostic[]; +} + +export function toAgentQuestRef(quest: QuestNode): AgentQuestRef { + return { + id: quest.id, + title: quest.title, + status: quest.status, + hours: quest.hours, + priority: quest.priority ?? DEFAULT_QUEST_PRIORITY, + taskKind: quest.taskKind, + assignedTo: quest.assignedTo, + }; +} + +export function buildAgentDependencyContext( + 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(toAgentQuestRef); + + const dependents = snapshot.quests + .filter((entry) => (entry.dependsOn ?? []).includes(quest.id)) + .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(toAgentQuestRef); + + 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; + 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, + ); + } + + 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: [], + diagnostics: [], + }; + } + + const quest = detail.questDetail.quest; + const readiness = await this.readiness.assess(id, { transition: false }); + const dependency = buildAgentDependencyContext(snapshot, quest); + 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; + const diagnostics = collectQuestDiagnostics(detail.questDetail, readiness); + + return { + detail, + readiness, + dependency, + recommendedActions, + diagnostics, + }; + } + + 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 new file mode 100644 index 0000000..d5b09bf --- /dev/null +++ b/src/domain/services/AgentRecommender.ts @@ -0,0 +1,142 @@ +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; + priority?: string; + 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, + private readonly agentId: string, + ) {} + + public async recommendForQuest( + quest: QuestNode, + readiness: ReadinessAssessment | null, + dependency: AgentDependencyContext, + ): Promise { + const seeds: CandidateSeed[] = []; + + if ( + quest.status === 'READY' && + dependency.isFrontier && + ( + quest.assignedTo === undefined || + quest.assignedTo === this.agentId + ) + ) { + 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/src/domain/services/AgentSubmissionService.ts b/src/domain/services/AgentSubmissionService.ts new file mode 100644 index 0000000..f60689e --- /dev/null +++ b/src/domain/services/AgentSubmissionService.ts @@ -0,0 +1,243 @@ +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'; +} + +export function isReviewableByAgent(submission: SubmissionNode, agentId: string): boolean { + return ( + submission.submittedBy !== agentId && + submission.status === 'OPEN' + ); +} + +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: determineSubmissionNextStep(submission, this.agentId), + }; + } +} + +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 === 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 === 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: '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/src/domain/services/DiagnosticService.ts b/src/domain/services/DiagnosticService.ts new file mode 100644 index 0000000..2536e7c --- /dev/null +++ b/src/domain/services/DiagnosticService.ts @@ -0,0 +1,227 @@ +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, + type SettlementGateAssessment, +} 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 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, +): Diagnostic[] { + const diagnostics: Diagnostic[] = []; + const quest = detail.quest; + + diagnostics.push(...collectReadinessDiagnostics(readiness, quest.id)); + + 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'); + diagnostics.push(...settlementAssessmentToDiagnostics(settlement)); + } + + return dedupeDiagnostics(diagnostics); +} diff --git a/src/domain/services/DoctorService.ts b/src/domain/services/DoctorService.ts new file mode 100644 index 0000000..631c4c5 --- /dev/null +++ b/src/domain/services/DoctorService.ts @@ -0,0 +1,596 @@ +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, +} 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; + blockingIssueCount: 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[]; + diagnostics: Diagnostic[]; +} + +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, + blockingIssueCount: errorCount, + 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 diagnostics = issues.map(doctorIssueToDiagnostic); + + 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, + diagnostics, + }; + } + + 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/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/SettlementGateService.ts b/src/domain/services/SettlementGateService.ts new file mode 100644 index 0000000..b589186 --- /dev/null +++ b/src/domain/services/SettlementGateService.ts @@ -0,0 +1,272 @@ +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' + | 'governed-work-failing-evidence'; + +export interface SettlementGateAssessment { + allowed: boolean; + questId: string; + governed: boolean; + action: SettlementAction; + policyId?: string; + allowManualSeal?: boolean; + submissionId?: string; + submissionStatus?: string; + 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'; + } +} + +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, +): 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 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, + failingCriterionIds: computed?.failingCriterionIds ?? [], + linkedOnlyCriterionIds: computed?.linkedOnlyCriterionIds ?? [], + missingCriterionIds: computed?.missingCriterionIds ?? [], + }; + } + + if (appliedPolicy.allowManualSeal) { + if (approvalAssessment) return approvalAssessment; + return { + allowed: true, + questId, + governed: true, + action, + policyId: appliedPolicy.id, + allowManualSeal: true, + submissionId: submission?.id, + submissionStatus: submission?.status, + 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, + submissionId: submission?.id, + submissionStatus: submission?.status, + code: 'missing-computed-completion', + failingCriterionIds: [], + linkedOnlyCriterionIds: [], + missingCriterionIds: [], + }; + } + + if (computed.complete) { + if (approvalAssessment) return approvalAssessment; + return { + allowed: true, + questId, + governed: true, + action, + policyId: appliedPolicy.id, + allowManualSeal: false, + submissionId: submission?.id, + submissionStatus: submission?.status, + 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, + submissionId: submission?.id, + submissionStatus: submission?.status, + 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}.`; + } + 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[] = [ + `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, + submissionId: assessment.submissionId ?? null, + submissionStatus: assessment.submissionStatus ?? 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/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 2479542..70d1b07 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)) { @@ -168,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( @@ -181,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/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..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, @@ -25,6 +26,7 @@ import { computeStatus, computeTipPatchset, computeEffectiveVerdicts, + filterIndependentVerdicts, type PatchsetRef, type ReviewRef, type DecisionProps, @@ -63,6 +65,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, @@ -347,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']; @@ -365,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, @@ -384,19 +389,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 +688,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[] = []; @@ -920,6 +1016,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) : []; @@ -929,6 +1026,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); @@ -991,6 +1090,7 @@ class GraphContextImpl implements GraphContext { title: string; authoredBy: string; authoredAt: number; + noteKind?: string; targetIds: string[]; supersedesId?: string; }>(); @@ -1031,6 +1131,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, }); @@ -1092,6 +1195,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)), @@ -1349,21 +1453,29 @@ 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], }); } 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, }); @@ -1484,12 +1596,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/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/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/infrastructure/adapters/WarpSubmissionAdapter.ts b/src/infrastructure/adapters/WarpSubmissionAdapter.ts index 8a81373..800f38c 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( @@ -248,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/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/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/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/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/GraphContextEntityDetail.test.ts b/test/integration/GraphContextEntityDetail.test.ts index d98dceb..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'); @@ -159,6 +205,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']); @@ -183,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'], @@ -193,14 +252,84 @@ 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 () => { + 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/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/AgentActionService.test.ts b/test/unit/AgentActionService.test.ts new file mode 100644 index 0000000..41c1e53 --- /dev/null +++ b/test/unit/AgentActionService.test.ts @@ -0,0 +1,905 @@ +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(), + getPatchsetMergeRef: 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', () => ({ + 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); + } + + 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) { + return mocks.submit(args); + } + + review(args: unknown) { + 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); + } + + getPatchsetMergeRef(patchsetId: string) { + return mocks.getPatchsetMergeRef(patchsetId); + } + + getSubmissionQuestId(submissionId: string) { + return mocks.getSubmissionQuestId(submissionId); + } + + getQuestStatus(questId: string) { + return mocks.getQuestStatus(questId); + } + }, +})); + +vi.mock('../../src/infrastructure/adapters/GitWorkspaceAdapter.js', () => ({ + GitWorkspaceAdapter: class GitWorkspaceAdapter { + getWorkspaceRef() { + return mocks.getWorkspaceRef(); + } + + getHeadCommit(ref: string) { + return mocks.getHeadCommit(ref); + } + + getCommitsSince(base: string, ref?: string) { + return mocks.getCommitsSince(base, ref); + } + + isMerged(ref: string, into: string) { + return mocks.isMerged(ref, into); + } + + merge(ref: string, into: string) { + return mocks.merge(ref, into); + } + }, +})); + +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'), + }; +} + +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', + }, + }, + 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: [], + 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.getPatchsetMergeRef.mockResolvedValue('abc123def456'); + 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 () => { + 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('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'), + }; + 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', + }, + }); + }); + + 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 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('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) => { + 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', + mergeRef: 'abc123def456', + 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('abc123def456', '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('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( + 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('abc123def456', 'main'); + expect(outcome).toMatchObject({ + kind: 'merge', + targetId: 'submission:AGT-001', + allowed: true, + result: 'partial-failure', + 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({}), + 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', 'feat/agent-action-kernel-v1'); + 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/AgentBriefingService.test.ts b/test/unit/AgentBriefingService.test.ts new file mode 100644 index 0000000..26ad34e --- /dev/null +++ b/test/unit/AgentBriefingService.test.ts @@ -0,0 +1,537 @@ +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 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(async () => graph), + 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] ?? []), + }; +} + +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(); + }); + + 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, + tipPatchsetId: 'patchset:AGT-001', + }), + ], + 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( + 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, + { + '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', + makeDoctor(), + ); + + 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', + nextStep: { + kind: 'review', + targetId: 'patchset:AGT-001', + supportedByActionKernel: true, + }, + }, + ]); + 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.diagnostics).toEqual([]); + 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( + makeGraphWithHandoffs([]), + 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', + makeDoctor(), + ); + + const result = await service.next(5); + + expect(result.diagnostics).toEqual([]); + expect(result.candidates).toHaveLength(2); + expect(result.candidates[0]).toMatchObject({ + kind: 'claim', + targetId: 'task:AGT-READY', + source: 'assignment', + }); + expect(result.candidates[1]).toMatchObject({ + kind: 'ready', + targetId: 'task:AGT-PLAN', + source: 'planning', + }); + }); + + 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', + makeDoctor(), + ); + + const result = await service.next(5); + + expect(result.diagnostics).toEqual([]); + expect(result.candidates).toHaveLength(2); + expect(result.candidates[0]).toMatchObject({ + kind: 'merge', + targetId: 'submission:AGT-MERGE', + source: 'submission', + allowed: false, + validationCode: 'requires-additional-input', + args: { intoRef: 'main' }, + }); + expect(result.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: [ + 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', + makeDoctor(), + ); + + const briefing = await service.buildBriefing(); + + expect(briefing.reviewQueue).toEqual([]); + }); +}); diff --git a/test/unit/AgentCommands.test.ts b/test/unit/AgentCommands.test.ts new file mode 100644 index 0000000..26e5596 --- /dev/null +++ b/test/unit/AgentCommands.test.ts @@ -0,0 +1,1046 @@ +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(), + fetchContext: vi.fn(), + buildBriefing: vi.fn(), + nextCandidates: vi.fn(), + listSubmissions: 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/domain/services/AgentContextService.js', () => ({ + AgentContextService: class AgentContextService { + fetch(id: string) { + return mocks.fetchContext(id); + } + }, +})); + +vi.mock('../../src/domain/services/AgentBriefingService.js', () => ({ + AgentBriefingService: class AgentBriefingService { + buildBriefing() { + return mocks.buildBriefing(); + } + + next(limit: number) { + return mocks.nextCandidates(limit); + } + }, +})); + +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); + }, +})); + +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 a JSON briefing packet', async () => { + mocks.buildBriefing.mockResolvedValue({ + identity: { + agentId: 'agent.hal', + principalType: 'agent', + }, + assignments: [], + reviewQueue: [], + frontier: [], + recentHandoffs: [], + alerts: [], + diagnostics: [], + 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', + diagnostics: [], + data: { + identity: { + agentId: 'agent.hal', + principalType: 'agent', + }, + assignments: [], + reviewQueue: [], + frontier: [], + recentHandoffs: [], + alerts: [], + diagnostics: [], + graphMeta: { + maxTick: 42, + myTick: 7, + writerCount: 3, + tipSha: 'abc1234', + }, + }, + }); + }); + + it('emits a JSON next-candidate list', async () => { + 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(); + registerAgentCommands(program, ctx); + + await program.parseAsync(['next', '--limit', '3'], { from: 'user' }); + + expect(mocks.nextCandidates).toHaveBeenCalledWith(3); + expect(ctx.jsonOut).toHaveBeenCalledWith({ + success: true, + command: 'next', + diagnostics: [], + 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: { + 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, + }], + diagnostics: [], + }); + + 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', + diagnostics: [], + 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, + }], + diagnostics: [], + }, + }, + }); + }); + + 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: true, + }, + }, + ], + 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: true, + }, + }, + ], + 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: true, + }, + }, + ], + }); + + 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: true, + }, + }, + ], + 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: true, + }, + }, + ], + 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: true, + }, + }, + ], + }, + }); + }); + + 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('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('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('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', + 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', + 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(); + }); + + 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(); + }); +}); diff --git a/test/unit/AgentContextService.test.ts b/test/unit/AgentContextService.test.ts new file mode 100644 index 0000000..762c250 --- /dev/null +++ b/test/unit/AgentContextService.test.ts @@ -0,0 +1,374 @@ +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, submission } 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, + }); + }); + + 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/AgentSubmissionService.test.ts b/test/unit/AgentSubmissionService.test.ts new file mode 100644 index 0000000..baaf320 --- /dev/null +++ b/test/unit/AgentSubmissionService.test.ts @@ -0,0 +1,247 @@ +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, + determineSubmissionNextStep, +} 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: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', + 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', + supportedByActionKernel: true, + }, + }, + ]); + 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', + ]); + 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', + supportedByActionKernel: true, + }, + }); + }); + + 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'); + }); + + 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, + }); + }); +}); 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/DashboardTraceCommand.test.ts b/test/unit/DashboardTraceCommand.test.ts index 3a42ec2..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, @@ -120,10 +194,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: [], }, }); }); @@ -186,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, @@ -194,6 +283,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 +297,10 @@ describe('dashboard trace view JSON', () => { }], untestedCriteria: ['criterion:LINKED'], failingCriteria: ['criterion:FAILED'], + questCompletion: [], + campaignCompletion: [], + questDiscrepancies: [], + campaignDiscrepancies: [], }), })); }); 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 new file mode 100644 index 0000000..743964e --- /dev/null +++ b/test/unit/DoctorCommands.test.ts @@ -0,0 +1,213 @@ +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, + blockingIssueCount: 0, + 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: [], + }, + ], + 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); + + 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, + diagnostics: report.diagnostics, + }); + 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, + blockingIssueCount: 2, + errorCount: 2, + warningCount: 0, + danglingEdges: 1, + orphanNodes: 1, + readinessGaps: 0, + sovereigntyViolations: 0, + 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); + + 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, + report.diagnostics, + ); + 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..e5be59f --- /dev/null +++ b/test/unit/DoctorService.test.ts @@ -0,0 +1,424 @@ +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.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', + 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/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/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', () => { 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/SettlementGateService.test.ts b/test/unit/SettlementGateService.test.ts new file mode 100644 index 0000000..abc9106 --- /dev/null +++ b/test/unit/SettlementGateService.test.ts @@ -0,0 +1,171 @@ +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', + }, + }, + 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: [], + 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: [], + }), '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: { + ...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/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', diff --git a/test/unit/SignedSettlementCommands.test.ts b/test/unit/SignedSettlementCommands.test.ts index 2103d02..4cc8d4c 100644 --- a/test/unit/SignedSettlementCommands.test.ts +++ b/test/unit/SignedSettlementCommands.test.ts @@ -1,6 +1,8 @@ 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, registerArtifactCommands, @@ -13,14 +15,20 @@ 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(), + getPatchsetMergeRef: 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(), + fetchEntityDetail: vi.fn(), })); vi.mock('../../src/domain/services/GuildSealService.js', () => ({ @@ -51,10 +59,18 @@ 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); } + getPatchsetMergeRef(id: string): Promise { + return mocks.getPatchsetMergeRef(id); + } + getSubmissionQuestId(id: string): Promise { return mocks.getSubmissionQuestId(id); } @@ -71,6 +87,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); } @@ -79,6 +99,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); } @@ -93,6 +121,85 @@ 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', + }, + }, + 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: [], + 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', @@ -108,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; }, @@ -147,14 +261,20 @@ 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.getPatchsetMergeRef.mockResolvedValue('feedfacecafebeef'); mocks.getSubmissionQuestId.mockResolvedValue('task:Q1'); mocks.getQuestStatus.mockResolvedValue('PLANNED'); mocks.decide.mockResolvedValue({ patchSha: 'patch:decision' }); mocks.isMerged.mockResolvedValue(false); mocks.merge.mockResolvedValue('abcdef1234567890'); mocks.getHeadCommit.mockResolvedValue('abcdef1234567890'); + mocks.fetchEntityDetail.mockResolvedValue(makeQuestDetail()); }); afterEach(() => { @@ -252,6 +372,131 @@ 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('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'), + diagnostics: [ + expect.objectContaining({ + code: 'settlement-approved-submission-required', + category: 'workflow', + }), + ], + 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); @@ -281,4 +526,73 @@ 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: { + ...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'), + diagnostics: [ + expect.objectContaining({ + code: 'settlement-governed-work-linked-only', + category: 'workflow', + }), + ], + data: { + submissionId: 'submission:Q1', + action: 'merge', + questId: 'task:Q1', + governed: true, + policyId: 'policy:TRACE', + verdict: 'LINKED', + linkedOnlyCriterionIds: ['criterion:Q1'], + }, + }); + }); }); diff --git a/test/unit/SubmissionService.test.ts b/test/unit/SubmissionService.test.ts index bb77111..e262e41 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]'); + }); }); // --------------------------------------------------------------------------- @@ -208,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 () => { @@ -223,6 +250,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', 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[] = [ 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-actuator.ts b/xyph-actuator.ts index 41be086..1964b6e 100755 --- a/xyph-actuator.ts +++ b/xyph-actuator.ts @@ -16,6 +16,8 @@ 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'; +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. @@ -53,5 +55,7 @@ registerSuggestionCommands(program, ctx); registerAnalyzeCommands(program, ctx); registerIdentityCommands(program, ctx); registerShowCommands(program, ctx); +registerAgentCommands(program, ctx); +registerDoctorCommands(program, ctx); await program.parseAsync(process.argv); 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); }