Merge story-31: View Upcoming Stories

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

# Conflicts:
#	frontend/src/api/workflow.ts
#	frontend/src/components/Chat.test.tsx
#	frontend/src/components/Chat.tsx
#	server/src/http/workflow.rs
This commit is contained in:
Dave
2026-02-19 15:54:02 +00:00
11 changed files with 521 additions and 2 deletions

View File

@@ -1,6 +1,6 @@
--- ---
name: View Upcoming Stories name: View Upcoming Stories
test_plan: pending test_plan: approved
--- ---
# Story 31: View Upcoming Stories # Story 31: View Upcoming Stories

View File

@@ -0,0 +1,28 @@
# Story 34: Agent Security and Sandboxing
## User Story
**As a** supervisor orchestrating multiple autonomous agents,
**I want to** constrain what each agent can access and do,
**So that** agents can't escape their worktree, damage shared state, or perform unintended actions.
## Acceptance Criteria
- [ ] Agent creation accepts an `allowed_tools` list to restrict Claude Code tool access per agent.
- [ ] Agent creation accepts a `disallowed_tools` list as an alternative to allowlisting.
- [ ] Agents without Bash access can still perform useful coding work (Read, Edit, Write, Glob, Grep).
- [ ] Investigate replacing direct Bash/shell access with Rust-implemented tool proxies that enforce boundaries:
- Scoped `exec_shell` that only runs allowlisted commands (e.g., `cargo test`, `npm test`) within the agent's worktree.
- Scoped `read_file` / `write_file` that reject paths outside the agent's worktree root.
- Scoped `git` operations that only work within the agent's worktree.
- [ ] Evaluate `--max-turns` and `--max-budget-usd` as safety limits for runaway agents.
- [ ] Document the trust model: what the supervisor controls vs what agents can do autonomously.
## Questions to Explore
- Can we use MCP (Model Context Protocol) to expose our Rust-implemented tools to Claude Code, replacing its built-in Bash/filesystem tools with scoped versions?
- What's the right granularity for shell allowlists — command-level (`cargo test`) or pattern-level (`cargo *`)?
- Should agents have read access outside their worktree (e.g., to reference shared specs) but write access only within it?
- Is OS-level sandboxing (Docker, macOS sandbox profiles) worth the complexity for a personal tool?
## Out of Scope
- Multi-user authentication or authorization (single-user personal tool).
- Network-level isolation between agents.
- Encrypting agent communication channels (all local).

View File

@@ -40,6 +40,7 @@ vi.mock("./api/workflow", () => {
missing_categories: [], missing_categories: [],
}), }),
getReviewQueueAll: vi.fn().mockResolvedValue({ stories: [] }), getReviewQueueAll: vi.fn().mockResolvedValue({ stories: [] }),
getUpcomingStories: vi.fn().mockResolvedValue({ stories: [] }),
recordTests: vi.fn(), recordTests: vi.fn(),
ensureAcceptance: vi.fn(), ensureAcceptance: vi.fn(),
getReviewQueue: vi.fn(), getReviewQueue: vi.fn(),

View File

@@ -98,6 +98,28 @@ describe("workflowApi", () => {
}); });
}); });
describe("getUpcomingStories", () => {
it("sends GET to /workflow/upcoming", async () => {
const response = {
stories: [
{ story_id: "31_view_upcoming", name: "View Upcoming" },
{ story_id: "32_worktree", name: null },
],
};
mockFetch.mockResolvedValueOnce(okResponse(response));
const result = await workflowApi.getUpcomingStories();
expect(mockFetch).toHaveBeenCalledWith(
"/api/workflow/upcoming",
expect.objectContaining({}),
);
expect(result.stories).toHaveLength(2);
expect(result.stories[0].name).toBe("View Upcoming");
expect(result.stories[1].name).toBeNull();
});
});
describe("getReviewQueue", () => { describe("getReviewQueue", () => {
it("sends GET to /workflow/review", async () => { it("sends GET to /workflow/review", async () => {
mockFetch.mockResolvedValueOnce( mockFetch.mockResolvedValueOnce(

View File

@@ -72,6 +72,15 @@ export interface TodoListResponse {
stories: StoryTodosResponse[]; stories: StoryTodosResponse[];
} }
export interface UpcomingStory {
story_id: string;
name: string | null;
}
export interface UpcomingStoriesResponse {
stories: UpcomingStory[];
}
const DEFAULT_API_BASE = "/api"; const DEFAULT_API_BASE = "/api";
function buildApiUrl(path: string, baseUrl = DEFAULT_API_BASE): string { function buildApiUrl(path: string, baseUrl = DEFAULT_API_BASE): string {
@@ -134,6 +143,13 @@ export const workflowApi = {
getReviewQueueAll(baseUrl?: string) { getReviewQueueAll(baseUrl?: string) {
return requestJson<ReviewListResponse>("/workflow/review/all", {}, baseUrl); return requestJson<ReviewListResponse>("/workflow/review/all", {}, baseUrl);
}, },
getUpcomingStories(baseUrl?: string) {
return requestJson<UpcomingStoriesResponse>(
"/workflow/upcoming",
{},
baseUrl,
);
},
ensureAcceptance(payload: AcceptanceRequest, baseUrl?: string) { ensureAcceptance(payload: AcceptanceRequest, baseUrl?: string) {
return requestJson<boolean>( return requestJson<boolean>(
"/workflow/acceptance/ensure", "/workflow/acceptance/ensure",

View File

@@ -36,6 +36,7 @@ vi.mock("../api/workflow", () => {
recordCoverage: vi.fn(), recordCoverage: vi.fn(),
collectCoverage: vi.fn(), collectCoverage: vi.fn(),
getStoryTodos: vi.fn(), getStoryTodos: vi.fn(),
getUpcomingStories: vi.fn(),
}, },
}; };
}); });
@@ -56,6 +57,7 @@ const mockedWorkflow = {
getReviewQueueAll: vi.mocked(workflowApi.getReviewQueueAll), getReviewQueueAll: vi.mocked(workflowApi.getReviewQueueAll),
ensureAcceptance: vi.mocked(workflowApi.ensureAcceptance), ensureAcceptance: vi.mocked(workflowApi.ensureAcceptance),
getStoryTodos: vi.mocked(workflowApi.getStoryTodos), getStoryTodos: vi.mocked(workflowApi.getStoryTodos),
getUpcomingStories: vi.mocked(workflowApi.getUpcomingStories),
}; };
describe("Chat review panel", () => { describe("Chat review panel", () => {
@@ -78,6 +80,7 @@ describe("Chat review panel", () => {
mockedWorkflow.getReviewQueueAll.mockResolvedValue({ stories: [] }); mockedWorkflow.getReviewQueueAll.mockResolvedValue({ stories: [] });
mockedWorkflow.ensureAcceptance.mockResolvedValue(true); mockedWorkflow.ensureAcceptance.mockResolvedValue(true);
mockedWorkflow.getStoryTodos.mockResolvedValue({ stories: [] }); mockedWorkflow.getStoryTodos.mockResolvedValue({ stories: [] });
mockedWorkflow.getUpcomingStories.mockResolvedValue({ stories: [] });
}); });
it("shows an empty review queue state", async () => { it("shows an empty review queue state", async () => {
@@ -469,6 +472,23 @@ describe("Chat review panel", () => {
expect(await screen.findByText(/Coverage: 92\.0%/)).toBeInTheDocument(); expect(await screen.findByText(/Coverage: 92\.0%/)).toBeInTheDocument();
}); });
it("fetches upcoming stories on mount and renders panel", async () => {
mockedWorkflow.getUpcomingStories.mockResolvedValueOnce({
stories: [
{ story_id: "31_view_upcoming", name: "View Upcoming Stories" },
{ story_id: "32_worktree", name: null },
],
});
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
expect(await screen.findByText("Upcoming Stories")).toBeInTheDocument();
expect(
await screen.findByText("View Upcoming Stories"),
).toBeInTheDocument();
expect(await screen.findByText("32_worktree")).toBeInTheDocument();
});
it("collect coverage button triggers collection and refreshes gate", async () => { it("collect coverage button triggers collection and refreshes gate", async () => {
const mockedCollectCoverage = vi.mocked(workflowApi.collectCoverage); const mockedCollectCoverage = vi.mocked(workflowApi.collectCoverage);
mockedCollectCoverage.mockResolvedValueOnce({ mockedCollectCoverage.mockResolvedValueOnce({

View File

@@ -3,13 +3,14 @@ import Markdown from "react-markdown";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism"; import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
import { api, ChatWebSocket } from "../api/client"; import { api, ChatWebSocket } from "../api/client";
import type { ReviewStory } from "../api/workflow"; import type { ReviewStory, UpcomingStory } from "../api/workflow";
import { workflowApi } from "../api/workflow"; import { workflowApi } from "../api/workflow";
import type { Message, ProviderConfig, ToolCall } from "../types"; import type { Message, ProviderConfig, ToolCall } from "../types";
import { ChatHeader } from "./ChatHeader"; import { ChatHeader } from "./ChatHeader";
import { GatePanel } from "./GatePanel"; import { GatePanel } from "./GatePanel";
import { ReviewPanel } from "./ReviewPanel"; import { ReviewPanel } from "./ReviewPanel";
import { TodoPanel } from "./TodoPanel"; import { TodoPanel } from "./TodoPanel";
import { UpcomingPanel } from "./UpcomingPanel";
const { useCallback, useEffect, useRef, useState } = React; const { useCallback, useEffect, useRef, useState } = React;
@@ -68,6 +69,12 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
const [todoError, setTodoError] = useState<string | null>(null); const [todoError, setTodoError] = useState<string | null>(null);
const [isTodoLoading, setIsTodoLoading] = useState(false); const [isTodoLoading, setIsTodoLoading] = useState(false);
const [lastTodoRefresh, setLastTodoRefresh] = useState<Date | null>(null); const [lastTodoRefresh, setLastTodoRefresh] = useState<Date | null>(null);
const [upcomingStories, setUpcomingStories] = useState<UpcomingStory[]>([]);
const [upcomingError, setUpcomingError] = useState<string | null>(null);
const [isUpcomingLoading, setIsUpcomingLoading] = useState(false);
const [lastUpcomingRefresh, setLastUpcomingRefresh] = useState<Date | null>(
null,
);
const storyId = "26_establish_tdd_workflow_and_gates"; const storyId = "26_establish_tdd_workflow_and_gates";
const gateStatusColor = isGateLoading const gateStatusColor = isGateLoading
@@ -375,6 +382,58 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
} }
}; };
useEffect(() => {
let active = true;
setIsUpcomingLoading(true);
setUpcomingError(null);
workflowApi
.getUpcomingStories()
.then((response) => {
if (!active) return;
setUpcomingStories(response.stories);
setLastUpcomingRefresh(new Date());
})
.catch((error) => {
if (!active) return;
const message =
error instanceof Error
? error.message
: "Failed to load upcoming stories.";
setUpcomingError(message);
setUpcomingStories([]);
})
.finally(() => {
if (active) {
setIsUpcomingLoading(false);
}
});
return () => {
active = false;
};
}, []);
const refreshUpcomingStories = async () => {
setIsUpcomingLoading(true);
setUpcomingError(null);
try {
const response = await workflowApi.getUpcomingStories();
setUpcomingStories(response.stories);
setLastUpcomingRefresh(new Date());
} catch (error) {
const message =
error instanceof Error
? error.message
: "Failed to load upcoming stories.";
setUpcomingError(message);
setUpcomingStories([]);
} finally {
setIsUpcomingLoading(false);
}
};
const refreshReviewQueue = async () => { const refreshReviewQueue = async () => {
setIsReviewLoading(true); setIsReviewLoading(true);
setReviewError(null); setReviewError(null);
@@ -676,6 +735,14 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
lastTodoRefresh={lastTodoRefresh} lastTodoRefresh={lastTodoRefresh}
onRefresh={refreshTodos} onRefresh={refreshTodos}
/> />
<UpcomingPanel
stories={upcomingStories}
isLoading={isUpcomingLoading}
error={upcomingError}
lastRefresh={lastUpcomingRefresh}
onRefresh={refreshUpcomingStories}
/>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,76 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import type { UpcomingStory } from "../api/workflow";
import { UpcomingPanel } from "./UpcomingPanel";
const baseProps = {
stories: [] as UpcomingStory[],
isLoading: false,
error: null,
lastRefresh: null,
onRefresh: vi.fn(),
};
describe("UpcomingPanel", () => {
it("shows empty state when no stories", () => {
render(<UpcomingPanel {...baseProps} />);
expect(screen.getByText("No upcoming stories.")).toBeInTheDocument();
});
it("shows loading state", () => {
render(<UpcomingPanel {...baseProps} isLoading={true} />);
expect(screen.getByText("Loading upcoming stories...")).toBeInTheDocument();
});
it("shows error with retry button", async () => {
const onRefresh = vi.fn();
render(
<UpcomingPanel
{...baseProps}
error="Network error"
onRefresh={onRefresh}
/>,
);
expect(
screen.getByText(/Network error.*Use Refresh to try again\./),
).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Retry" }));
expect(onRefresh).toHaveBeenCalledOnce();
});
it("renders story list with names", () => {
const stories: UpcomingStory[] = [
{ story_id: "31_view_upcoming", name: "View Upcoming Stories" },
{ story_id: "32_worktree", name: "Worktree Orchestration" },
];
render(<UpcomingPanel {...baseProps} stories={stories} />);
expect(screen.getByText("View Upcoming Stories")).toBeInTheDocument();
expect(screen.getByText("Worktree Orchestration")).toBeInTheDocument();
expect(screen.getByText("31_view_upcoming")).toBeInTheDocument();
expect(screen.getByText("32_worktree")).toBeInTheDocument();
});
it("renders story without name using story_id", () => {
const stories: UpcomingStory[] = [{ story_id: "33_no_name", name: null }];
render(<UpcomingPanel {...baseProps} stories={stories} />);
expect(screen.getByText("33_no_name")).toBeInTheDocument();
});
it("calls onRefresh when Refresh clicked", async () => {
const onRefresh = vi.fn();
render(<UpcomingPanel {...baseProps} onRefresh={onRefresh} />);
await userEvent.click(screen.getByRole("button", { name: "Refresh" }));
expect(onRefresh).toHaveBeenCalledOnce();
});
it("disables Refresh while loading", () => {
render(<UpcomingPanel {...baseProps} isLoading={true} />);
expect(screen.getByRole("button", { name: "Refresh" })).toBeDisabled();
});
});

View File

@@ -0,0 +1,169 @@
import type { UpcomingStory } from "../api/workflow";
interface UpcomingPanelProps {
stories: UpcomingStory[];
isLoading: boolean;
error: string | null;
lastRefresh: Date | null;
onRefresh: () => void;
}
const formatTimestamp = (value: Date | null): string => {
if (!value) return "—";
return value.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
};
export function UpcomingPanel({
stories,
isLoading,
error,
lastRefresh,
onRefresh,
}: UpcomingPanelProps) {
return (
<div
style={{
border: "1px solid #333",
borderRadius: "10px",
padding: "12px 16px",
background: "#1f1f1f",
display: "flex",
flexDirection: "column",
gap: "8px",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "12px",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
}}
>
<div style={{ fontWeight: 600 }}>Upcoming Stories</div>
<button
type="button"
onClick={onRefresh}
disabled={isLoading}
style={{
padding: "4px 10px",
borderRadius: "999px",
border: "1px solid #333",
background: isLoading ? "#2a2a2a" : "#2f2f2f",
color: isLoading ? "#777" : "#aaa",
cursor: isLoading ? "not-allowed" : "pointer",
fontSize: "0.75em",
fontWeight: 600,
}}
>
Refresh
</button>
</div>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "flex-end",
gap: "2px",
fontSize: "0.85em",
color: "#aaa",
}}
>
<div>{stories.length} stories</div>
<div style={{ fontSize: "0.8em", color: "#777" }}>
Updated {formatTimestamp(lastRefresh)}
</div>
</div>
</div>
{isLoading ? (
<div style={{ fontSize: "0.85em", color: "#aaa" }}>
Loading upcoming stories...
</div>
) : error ? (
<div
style={{
fontSize: "0.85em",
color: "#ff7b72",
display: "flex",
alignItems: "center",
gap: "8px",
flexWrap: "wrap",
}}
>
<span>{error} Use Refresh to try again.</span>
<button
type="button"
onClick={onRefresh}
disabled={isLoading}
style={{
padding: "4px 10px",
borderRadius: "999px",
border: "1px solid #333",
background: "#2f2f2f",
color: "#aaa",
cursor: "pointer",
fontSize: "0.75em",
fontWeight: 600,
}}
>
Retry
</button>
</div>
) : stories.length === 0 ? (
<div style={{ fontSize: "0.85em", color: "#aaa" }}>
No upcoming stories.
</div>
) : (
<div
style={{
display: "flex",
flexDirection: "column",
gap: "6px",
}}
>
{stories.map((story) => (
<div
key={`upcoming-${story.story_id}`}
style={{
border: "1px solid #2a2a2a",
borderRadius: "8px",
padding: "8px 12px",
background: "#191919",
display: "flex",
alignItems: "center",
gap: "8px",
}}
>
<div style={{ fontWeight: 600, fontSize: "0.9em" }}>
{story.name ?? story.story_id}
</div>
{story.name && (
<div
style={{
fontSize: "0.75em",
color: "#777",
fontFamily: "monospace",
}}
>
{story.story_id}
</div>
)}
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -13,6 +13,21 @@ pub struct AppContext {
pub agents: Arc<AgentPool>, pub agents: Arc<AgentPool>,
} }
#[cfg(test)]
impl AppContext {
pub fn new_test(project_root: std::path::PathBuf) -> Self {
let state = SessionState::default();
*state.project_root.lock().unwrap() = Some(project_root.clone());
let store_path = project_root.join(".story_kit_store.json");
Self {
state: Arc::new(state),
store: Arc::new(JsonFileStore::new(store_path).unwrap()),
workflow: Arc::new(std::sync::Mutex::new(WorkflowState::default())),
agents: Arc::new(AgentPool::new()),
}
}
}
pub type OpenApiResult<T> = poem::Result<T>; pub type OpenApiResult<T> = poem::Result<T>;
pub fn bad_request(message: String) -> poem::Error { pub fn bad_request(message: String) -> poem::Error {

View File

@@ -99,6 +99,51 @@ struct TodoListResponse {
pub stories: Vec<StoryTodosResponse>, pub stories: Vec<StoryTodosResponse>,
} }
#[derive(Object)]
struct UpcomingStory {
pub story_id: String,
pub name: Option<String>,
}
#[derive(Object)]
struct UpcomingStoriesResponse {
pub stories: Vec<UpcomingStory>,
}
fn load_upcoming_stories(ctx: &AppContext) -> Result<Vec<UpcomingStory>, String> {
let root = ctx.state.get_project_root()?;
let upcoming_dir = root.join(".story_kit").join("stories").join("upcoming");
if !upcoming_dir.exists() {
return Ok(Vec::new());
}
let mut stories = Vec::new();
for entry in fs::read_dir(&upcoming_dir)
.map_err(|e| format!("Failed to read upcoming stories directory: {e}"))?
{
let entry = entry.map_err(|e| format!("Failed to read upcoming story entry: {e}"))?;
let path = entry.path();
if path.extension().and_then(|ext| ext.to_str()) != Some("md") {
continue;
}
let story_id = path
.file_stem()
.and_then(|stem| stem.to_str())
.ok_or_else(|| "Invalid story file name.".to_string())?
.to_string();
let contents = fs::read_to_string(&path)
.map_err(|e| format!("Failed to read story file {}: {e}", path.display()))?;
let name = parse_front_matter(&contents)
.ok()
.and_then(|meta| meta.name);
stories.push(UpcomingStory { story_id, name });
}
stories.sort_by(|a, b| a.story_id.cmp(&b.story_id));
Ok(stories)
}
fn load_current_story_metadata(ctx: &AppContext) -> Result<Vec<(String, StoryMetadata)>, String> { fn load_current_story_metadata(ctx: &AppContext) -> Result<Vec<(String, StoryMetadata)>, String> {
let root = ctx.state.get_project_root()?; let root = ctx.state.get_project_root()?;
let current_dir = root.join(".story_kit").join("stories").join("current"); let current_dir = root.join(".story_kit").join("stories").join("current");
@@ -460,6 +505,13 @@ impl WorkflowApi {
Ok(Json(TodoListResponse { stories })) Ok(Json(TodoListResponse { stories }))
} }
/// List upcoming stories from .story_kit/stories/upcoming/.
#[oai(path = "/workflow/upcoming", method = "get")]
async fn list_upcoming_stories(&self) -> OpenApiResult<Json<UpcomingStoriesResponse>> {
let stories = load_upcoming_stories(self.ctx.as_ref()).map_err(bad_request)?;
Ok(Json(UpcomingStoriesResponse { stories }))
}
/// Ensure a story can be accepted; returns an error when gates fail. /// Ensure a story can be accepted; returns an error when gates fail.
#[oai(path = "/workflow/acceptance/ensure", method = "post")] #[oai(path = "/workflow/acceptance/ensure", method = "post")]
async fn ensure_acceptance( async fn ensure_acceptance(
@@ -611,4 +663,57 @@ mod tests {
assert!(!review.can_accept); assert!(!review.can_accept);
assert_eq!(review.summary.failed, 1); assert_eq!(review.summary.failed, 1);
} }
#[test]
fn load_upcoming_returns_empty_when_no_dir() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().to_path_buf();
// No .story_kit directory at all
let ctx = crate::http::context::AppContext::new_test(root);
let result = load_upcoming_stories(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn load_upcoming_parses_metadata() {
let tmp = tempfile::tempdir().unwrap();
let upcoming = tmp.path().join(".story_kit/stories/upcoming");
fs::create_dir_all(&upcoming).unwrap();
fs::write(
upcoming.join("31_view_upcoming.md"),
"---\nname: View Upcoming\ntest_plan: pending\n---\n# Story\n",
)
.unwrap();
fs::write(
upcoming.join("32_worktree.md"),
"---\nname: Worktree Orchestration\ntest_plan: pending\n---\n# Story\n",
)
.unwrap();
let ctx = crate::http::context::AppContext::new_test(tmp.path().to_path_buf());
let stories = load_upcoming_stories(&ctx).unwrap();
assert_eq!(stories.len(), 2);
assert_eq!(stories[0].story_id, "31_view_upcoming");
assert_eq!(stories[0].name.as_deref(), Some("View Upcoming"));
assert_eq!(stories[1].story_id, "32_worktree");
assert_eq!(stories[1].name.as_deref(), Some("Worktree Orchestration"));
}
#[test]
fn load_upcoming_skips_non_md_files() {
let tmp = tempfile::tempdir().unwrap();
let upcoming = tmp.path().join(".story_kit/stories/upcoming");
fs::create_dir_all(&upcoming).unwrap();
fs::write(upcoming.join(".gitkeep"), "").unwrap();
fs::write(
upcoming.join("31_story.md"),
"---\nname: A Story\ntest_plan: pending\n---\n",
)
.unwrap();
let ctx = crate::http::context::AppContext::new_test(tmp.path().to_path_buf());
let stories = load_upcoming_stories(&ctx).unwrap();
assert_eq!(stories.len(), 1);
assert_eq!(stories[0].story_id, "31_story");
}
} }