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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ nxd resume → dispatcher → executor → agents (parallel per wave)
| `internal/llm/semaphore.go` | Concurrency limiter wrapping `llm.Client` (default 1 for single-GPU Ollama) |
| `internal/artifact/store.go` | Per-story artifact persistence (launch config, trace JSONL, diffs, QA/review results) |
| `internal/scratchboard/` | Cross-agent knowledge sharing (JSONL-backed, per-requirement) |
| `internal/criteria/` | Declarative success criteria (file_exists, file_contains, test_passes, coverage_above, command_succeeds) |
| `internal/criteria/` | Declarative success criteria (file_exists, file_contains, test_passes, coverage_above, command_succeeds); `format.go` is a pure formatter (`Format`/`FormatMarkdown`) that splits run-on acceptance-criteria blobs into discrete human-readable items |
| `internal/web/eventbus.go` | In-process pub/sub for instant WebSocket event push |
| `internal/web/static/app.js` | Web dashboard frontend: DAG SVG visualization, agents, pipeline, stories, activity, review gates |
| `internal/graph/export.go` | DAG export as JSON with nodes, edges, wave assignments |
Expand Down Expand Up @@ -135,6 +135,10 @@ kill <stale-pid>
rm -f ~/.nxd/nxd.lock ~/.nxd/events.jsonl ~/.nxd/nxd.db
```

## Current State (2026-06-24)

- **Human-readable acceptance criteria**: `internal/criteria/format.go` (`Format`/`FormatMarkdown`, pure, fully unit-tested) segments a run-on acceptance-criteria blob into discrete items — sentence-splitting that guards abbreviations (`e.g.`) and identifier periods (`WorldState.copy()`) and strips `-`/`*`/`•`/`1.` markers. Surfaces: `nxd review <story>` prints a `Description:` block + a bulleted `Acceptance Criteria:` block; the web snapshot exposes `acceptance_criteria_items` (story_id → []string) and the dashboard expands a detail row (description + criteria checklist) when a story title is clicked (`storyDetailRow`/`toggleStoryDetail` in `app.js`). The Tech-Lead prompt (`prompts.go`) and planner tool schema (`planner_tools.go`) now require 3-6 intent-first criteria, one per line.

## Current State (2026-06-02)

- **Production-readiness pass complete (2026-06-02)**: req-daemon, req-logs, Tech-Lead conflict resolver, post-merge integration build, devdb dashboard column + Databases panel all wired. Coverage backfilled where pure-Go (cli +3pp, criteria +4pp, docker +3pp); architectural ceilings for live-PG / live-Docker recorded.
Expand Down
15 changes: 14 additions & 1 deletion internal/agent/prompts.go
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,20 @@ Guidelines:
- Stories with score 1-3 should be simple enough for a junior developer
- Stories with score 4-5 need intermediate-level work
- Stories with score 6+ need senior-level architecture decisions
- Identify cross-story dependencies explicitly`,
- Identify cross-story dependencies explicitly

Human-readable acceptance criteria (REQUIRED FORMAT):
The acceptance_criteria field is read by humans clicking through the story, not
only by the implementing agent. Write it so a reader understands the INTENT.
- Write 3-6 discrete criteria, ONE per line, each starting with '- '.
- Each line is a single, self-contained, verifiable statement in plain language.
- Lead with the observable outcome (what is true when done), not the command.
Put any exact build/test command at the END of its line in parentheses.
Good: '- The parser rejects unknown verbs without crashing (verified by go test ./parser).'
Avoid: 'go test ./parser green. parser handles verbs. no panic.'
- Never pack multiple checks into one run-on sentence separated by periods.
- A non-author reading only the criteria should be able to explain what the
story delivers.`,

RoleSenior: `You are a Senior Developer on Team {team_name}.

Expand Down
53 changes: 53 additions & 0 deletions internal/cli/review_intent_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package cli

import (
"strings"
"testing"

"github.com/tzone85/nexus-dispatch/internal/state"
)

// TestRunReviewStory_ShowsIntent verifies that `nxd review <story>` — the path a
// human takes to "click through" a story — surfaces the description and the
// acceptance criteria as readable bullet items, so the intent is legible.
func TestRunReviewStory_ShowsIntent(t *testing.T) {
env := setupTestEnv(t)
seedTestReq(t, env, "req-rvi", "Review Intent", env.Dir)

evt := state.NewEvent(state.EventStoryCreated, "tech-lead", "s-rvi1", map[string]any{
"id": "s-rvi1",
"req_id": "req-rvi",
"title": "Domain model",
"complexity": 3,
"description": "Define the core domain entities for the world.",
"acceptance_criteria": "Failing tests written first. go test green. WorldState.copy() produces independent instance.",
})
env.Events.Append(evt)
env.Proj.Project(evt)

cmd := newReviewStoryCmd()
out, err := execCmd(t, cmd, env.Config, "s-rvi1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

if !strings.Contains(out, "Description:") {
t.Errorf("expected a Description: label, got:\n%s", out)
}
if !strings.Contains(out, "Define the core domain entities for the world.") {
t.Errorf("expected the description text, got:\n%s", out)
}
if !strings.Contains(out, "Acceptance Criteria:") {
t.Errorf("expected an Acceptance Criteria: label, got:\n%s", out)
}
// Each criterion should appear as its own bullet, not a run-on blob.
for _, want := range []string{
"- Failing tests written first.",
"- go test green.",
"- WorldState.copy() produces independent instance.",
} {
if !strings.Contains(out, want) {
t.Errorf("expected bullet %q in output, got:\n%s", want, out)
}
}
}
12 changes: 12 additions & 0 deletions internal/cli/review_story.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import (
"fmt"
"os"
"os/exec"
"strings"

"github.com/spf13/cobra"
"github.com/tzone85/nexus-dispatch/internal/criteria"
)

func newReviewStoryCmd() *cobra.Command {
Expand Down Expand Up @@ -37,6 +39,16 @@ func runReviewStory(cmd *cobra.Command, args []string) error {
fmt.Fprintf(out, "Story: %s\nID: %s\nStatus: %s\nComplexity: %d\nBranch: %s\n\n",
story.Title, story.ID, story.Status, story.Complexity, story.Branch)

// Surface the story's intent so a human reviewing the change can read the
// description and acceptance criteria as a clean bulleted list rather than
// a run-on technical blob.
if desc := strings.TrimSpace(story.Description); desc != "" {
fmt.Fprintf(out, "Description:\n%s\n\n", desc)
}
if ac := criteria.FormatMarkdown(story.AcceptanceCriteria); ac != "" {
fmt.Fprintf(out, "Acceptance Criteria:\n%s\n\n", ac)
}

if story.Branch != "" {
repoDir, _ := os.Getwd()
baseBranch := s.Config.Merge.BaseBranch
Expand Down
153 changes: 153 additions & 0 deletions internal/criteria/format.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
package criteria

// This file turns a story's acceptance-criteria string — often authored by the
// Tech-Lead LLM as a single run-on technical blob — into a list of discrete,
// human-readable items. The goal is that a person clicking through a story in
// the dashboard or CLI can read the criteria and understand the intent, rather
// than parsing a wall of text.
//
// All functions here are pure (no I/O), so the segmentation rules are pinned by
// unit tests rather than discovered at render time.

import "strings"

// abbreviations are lowercased tokens (sans trailing period) after which a
// "period + space" must NOT be treated as a sentence boundary. Without this,
// "e.g. encoding/json" would split mid-thought.
var abbreviations = map[string]bool{
"e.g": true, "i.e": true, "etc": true, "vs": true, "cf": true,
"al": true, "approx": true, "no": true, "inc": true, "ltd": true,
"mr": true, "ms": true, "mrs": true, "dr": true, "fig": true,
"st": true, "jr": true, "sr": true,
}

// Format splits a raw acceptance-criteria string into clean, readable items.
//
// - Multi-line input (or input with list markers) is split per line, with any
// leading bullet/number marker stripped.
// - Single-paragraph input is segmented into sentences, guarding against
// abbreviations (e.g.) and intra-identifier periods (WorldState.copy()).
//
// Empty or whitespace-only input returns nil.
func Format(raw string) []string {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return nil
}

var lines []string
if strings.Contains(trimmed, "\n") {
lines = strings.Split(trimmed, "\n")
} else {
lines = splitSentences(trimmed)
}

items := make([]string, 0, len(lines))
for _, line := range lines {
item := strings.TrimSpace(stripMarker(line))
if item != "" {
items = append(items, item)
}
}
if len(items) == 0 {
return nil
}
return items
}

// FormatMarkdown renders the formatted items as a dash-bulleted markdown list,
// or "" when there are no items.
func FormatMarkdown(raw string) string {
items := Format(raw)
if len(items) == 0 {
return ""
}
var b strings.Builder
for i, item := range items {
if i > 0 {
b.WriteByte('\n')
}
b.WriteString("- ")
b.WriteString(item)
}
return b.String()
}

// splitSentences segments a single paragraph on terminal punctuation followed by
// whitespace, keeping the punctuation with its sentence and skipping false
// boundaries (abbreviations, identifier periods with no trailing space).
func splitSentences(s string) []string {
var out []string
start := 0
for i := 0; i < len(s); i++ {
c := s[i]
if c != '.' && c != '!' && c != '?' {
continue
}
// A boundary requires whitespace immediately after the punctuation.
if i+1 >= len(s) || !isSpace(s[i+1]) {
continue
}
if c == '.' && isAbbreviation(s[start:i]) {
continue
}
out = append(out, strings.TrimSpace(s[start:i+1]))
// Advance past the run of whitespace to the next sentence.
j := i + 1
for j < len(s) && isSpace(s[j]) {
j++
}
start = j
i = j - 1
}
if start < len(s) {
if tail := strings.TrimSpace(s[start:]); tail != "" {
out = append(out, tail)
}
}
return out
}

// isAbbreviation reports whether the word ending the segment (the token
// immediately before the period) is a known abbreviation.
func isAbbreviation(segment string) bool {
end := len(segment)
wordStart := end
for wordStart > 0 && !isSpace(segment[wordStart-1]) {
wordStart--
}
word := strings.ToLower(segment[wordStart:end])
return abbreviations[word]
}

// stripMarker removes a single leading list marker: "-", "*", "•", "–", or an
// ordinal like "1." / "2)".
func stripMarker(line string) string {
s := strings.TrimSpace(line)
if s == "" {
return s
}
switch s[0] {
case '-', '*':
return strings.TrimSpace(s[1:])
}
// Unicode bullet/dash glyphs.
for _, glyph := range []string{"•", "–", "—", "‣", "·"} {
if strings.HasPrefix(s, glyph) {
return strings.TrimSpace(s[len(glyph):])
}
}
// Ordinal marker: digits followed by '.' or ')'.
digits := 0
for digits < len(s) && s[digits] >= '0' && s[digits] <= '9' {
digits++
}
if digits > 0 && digits < len(s) && (s[digits] == '.' || s[digits] == ')') {
return strings.TrimSpace(s[digits+1:])
}
return s
}

func isSpace(b byte) bool {
return b == ' ' || b == '\t' || b == '\n' || b == '\r' || b == '\v' || b == '\f'
}
Loading
Loading