Compare commits
20 Commits
83db282892
...
0a28aae041
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a28aae041 | ||
|
|
a7a8358cbb | ||
|
|
6b6cb525a7 | ||
|
|
27465b1130 | ||
|
|
e74c370c7e | ||
|
|
8defd5c671 | ||
|
|
a5c4fb553a | ||
|
|
a7772d1421 | ||
|
|
ed967403fb | ||
|
|
998b188ac7 | ||
|
|
115c9fd6df | ||
|
|
86694a4383 | ||
|
|
7b324ea96e | ||
|
|
744a12eeea | ||
|
|
cffe63680d | ||
|
|
f5fffd64b8 | ||
|
|
ad68bc912f | ||
|
|
d02d53d112 | ||
|
|
3ce7276e89 | ||
|
|
6d87e64859 |
@@ -0,0 +1,22 @@
|
||||
---
|
||||
name: "@ file references in web UI chat input"
|
||||
---
|
||||
|
||||
# Story 269: @ file references in web UI chat input
|
||||
|
||||
## User Story
|
||||
|
||||
As a user chatting in the web UI, I want to type @ to get an autocomplete overlay listing project files, so that I can reference specific files in my messages the same way Zed and Claude Code do.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Typing @ in the chat input triggers a file picker overlay
|
||||
- [ ] Overlay searches project files with fuzzy matching as the user types after @
|
||||
- [ ] Selecting a file inserts a reference into the message (e.g. @path/to/file.rs)
|
||||
- [ ] The referenced file contents are included as context when the message is sent to the LLM
|
||||
- [ ] Overlay is dismissable with Escape
|
||||
- [ ] Multiple @ references can be used in a single message
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
name: "Human QA gate with rejection flow"
|
||||
agent: coder-opus
|
||||
---
|
||||
|
||||
# Story 247: Human QA gate with rejection flow
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
name: "Matrix bot structured conversation history"
|
||||
---
|
||||
|
||||
# Story 266: Matrix bot structured conversation history
|
||||
|
||||
## User Story
|
||||
|
||||
As a user chatting with the Matrix bot, I want it to remember and own its prior responses naturally, so that conversations feel like talking to one continuous entity rather than a new instance each message.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Conversation history is passed as structured API messages (user/assistant turns) rather than a flattened text prefix
|
||||
- [ ] Claude recognises its prior responses as its own, maintaining consistent personality across a conversation
|
||||
- [ ] Per-room history survives server restarts (persisted to disk or database)
|
||||
- [ ] Rolling window trimming still applies to keep context bounded
|
||||
- [ ] Multi-user rooms still attribute messages to the correct sender
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
name: "MCP update_story tool should support front matter fields"
|
||||
---
|
||||
|
||||
# Story 267: MCP update_story tool should support front matter fields
|
||||
|
||||
## User Story
|
||||
|
||||
As an operator using the MCP tools, I want update_story to accept optional front matter fields (like agent, manual_qa, etc.) so that I can update story metadata without editing files by hand.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] update_story MCP tool accepts optional agent parameter to set/change the agent front matter field
|
||||
- [ ] update_story MCP tool accepts optional arbitrary front matter key-value pairs
|
||||
- [ ] Front matter updates are auto-committed via the filesystem watcher like other story mutations
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
name: "Upgrade tokio-tungstenite to 0.29.0"
|
||||
---
|
||||
|
||||
# Refactor 268: Upgrade tokio-tungstenite to 0.29.0
|
||||
|
||||
## Current State
|
||||
|
||||
- TBD
|
||||
|
||||
## Desired State
|
||||
|
||||
Upgrade tokio-tungstenite from 0.28.0 to 0.29.0 in workspace Cargo.toml and fix any breaking API changes.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] tokio-tungstenite = "0.29.0" in workspace Cargo.toml
|
||||
- [ ] All code compiles without errors
|
||||
- [ ] All tests pass
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -3997,7 +3997,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "story-kit"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"async-stream",
|
||||
"async-trait",
|
||||
|
||||
@@ -625,16 +625,17 @@ describe("Chat localStorage persistence (Story 145)", () => {
|
||||
|
||||
// Verify sendChat was called with ALL prior messages + the new one
|
||||
expect(lastSendChatArgs).not.toBeNull();
|
||||
expect(lastSendChatArgs?.messages).toHaveLength(3);
|
||||
expect(lastSendChatArgs?.messages[0]).toEqual({
|
||||
const args = lastSendChatArgs!;
|
||||
expect(args.messages).toHaveLength(3);
|
||||
expect(args.messages[0]).toEqual({
|
||||
role: "user",
|
||||
content: "What is Rust?",
|
||||
});
|
||||
expect(lastSendChatArgs?.messages[1]).toEqual({
|
||||
expect(args.messages[1]).toEqual({
|
||||
role: "assistant",
|
||||
content: "Rust is a systems programming language.",
|
||||
});
|
||||
expect(lastSendChatArgs?.messages[2]).toEqual({
|
||||
expect(args.messages[2]).toEqual({
|
||||
role: "user",
|
||||
content: "Tell me more",
|
||||
});
|
||||
@@ -1343,7 +1344,7 @@ describe("Bug 264: Claude Code session ID persisted across browser refresh", ()
|
||||
|
||||
expect(lastSendChatArgs).not.toBeNull();
|
||||
expect(
|
||||
(lastSendChatArgs?.config as Record<string, unknown>).session_id,
|
||||
(lastSendChatArgs!.config as Record<string, unknown>).session_id,
|
||||
).toBe("persisted-session-xyz");
|
||||
});
|
||||
|
||||
|
||||
@@ -71,20 +71,93 @@ ls -lh "${DIST}"/
|
||||
echo "==> Generating changelog..."
|
||||
PREV_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
|
||||
if [ -n "$PREV_TAG" ]; then
|
||||
CHANGELOG=$(git log "${PREV_TAG}..HEAD" --pretty=format:"- %s" --no-merges)
|
||||
LOG_RANGE="${PREV_TAG}..HEAD"
|
||||
RANGE="${PREV_TAG}...${TAG}"
|
||||
else
|
||||
CHANGELOG=$(git log --pretty=format:"- %s" --no-merges)
|
||||
LOG_RANGE=""
|
||||
RANGE="initial...${TAG}"
|
||||
fi
|
||||
|
||||
if [ -z "$CHANGELOG" ]; then
|
||||
CHANGELOG="- No changes since last release"
|
||||
# Extract completed stories/bugs/refactors from "story-kit: merge <id>" commits.
|
||||
# Deduplicate (a story may have been merged more than once after reverts).
|
||||
if [ -n "$LOG_RANGE" ]; then
|
||||
MERGED_RAW=$(git log "$LOG_RANGE" --pretty=format:"%s" --no-merges \
|
||||
| grep "^story-kit: merge " | sed 's/^story-kit: merge //' | sort -u)
|
||||
else
|
||||
MERGED_RAW=$(git log --pretty=format:"%s" --no-merges \
|
||||
| grep "^story-kit: merge " | sed 's/^story-kit: merge //' | sort -u)
|
||||
fi
|
||||
|
||||
RELEASE_BODY="## What's Changed
|
||||
# Categorise merged work items and format names.
|
||||
FEATURES=""
|
||||
FIXES=""
|
||||
REFACTORS=""
|
||||
while IFS= read -r item; do
|
||||
[ -z "$item" ] && continue
|
||||
# Strip the numeric prefix and type to get the human name.
|
||||
name=$(echo "$item" | sed -E 's/^[0-9]+_(story|bug|refactor|spike)_//' | tr '_' ' ')
|
||||
# Capitalise first letter.
|
||||
name="$(echo "${name:0:1}" | tr '[:lower:]' '[:upper:]')${name:1}"
|
||||
case "$item" in
|
||||
*_bug_*) FIXES="${FIXES}- ${name}\n" ;;
|
||||
*_refactor_*) REFACTORS="${REFACTORS}- ${name}\n" ;;
|
||||
*) FEATURES="${FEATURES}- ${name}\n" ;;
|
||||
esac
|
||||
done <<< "$MERGED_RAW"
|
||||
|
||||
${CHANGELOG}
|
||||
# Collect non-automation manual commits (direct fixes, version bumps, etc).
|
||||
if [ -n "$LOG_RANGE" ]; then
|
||||
MANUAL=$(git log "$LOG_RANGE" --pretty=format:"%s" --no-merges \
|
||||
| grep -v "^story-kit: " \
|
||||
| grep -v "^Revert \"story-kit: " \
|
||||
| grep -v "^Bump version" \
|
||||
| sed 's/^/- /')
|
||||
else
|
||||
MANUAL=$(git log --pretty=format:"%s" --no-merges \
|
||||
| grep -v "^story-kit: " \
|
||||
| grep -v "^Revert \"story-kit: " \
|
||||
| grep -v "^Bump version" \
|
||||
| sed 's/^/- /')
|
||||
fi
|
||||
|
||||
# Assemble the release body.
|
||||
RELEASE_BODY="## What's Changed"
|
||||
|
||||
if [ -n "$FEATURES" ]; then
|
||||
RELEASE_BODY="${RELEASE_BODY}
|
||||
|
||||
### Features
|
||||
$(echo -e "$FEATURES")"
|
||||
fi
|
||||
|
||||
if [ -n "$FIXES" ]; then
|
||||
RELEASE_BODY="${RELEASE_BODY}
|
||||
|
||||
### Bug Fixes
|
||||
$(echo -e "$FIXES")"
|
||||
fi
|
||||
|
||||
if [ -n "$REFACTORS" ]; then
|
||||
RELEASE_BODY="${RELEASE_BODY}
|
||||
|
||||
### Refactors
|
||||
$(echo -e "$REFACTORS")"
|
||||
fi
|
||||
|
||||
if [ -n "$MANUAL" ]; then
|
||||
RELEASE_BODY="${RELEASE_BODY}
|
||||
|
||||
### Other Changes
|
||||
${MANUAL}"
|
||||
fi
|
||||
|
||||
if [ -z "$FEATURES" ] && [ -z "$FIXES" ] && [ -z "$REFACTORS" ] && [ -z "$MANUAL" ]; then
|
||||
RELEASE_BODY="${RELEASE_BODY}
|
||||
|
||||
- No changes since last release"
|
||||
fi
|
||||
|
||||
RELEASE_BODY="${RELEASE_BODY}
|
||||
|
||||
**Full diff:** ${GITEA_URL}/${REPO}/compare/${RANGE}"
|
||||
|
||||
|
||||
@@ -4,8 +4,7 @@ use std::process::Command;
|
||||
use crate::io::story_metadata::clear_front_matter_field;
|
||||
use crate::slog;
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn item_type_from_id(item_id: &str) -> &'static str {
|
||||
pub(super) fn item_type_from_id(item_id: &str) -> &'static str {
|
||||
// New format: {digits}_{type}_{slug}
|
||||
let after_num = item_id.trim_start_matches(|c: char| c.is_ascii_digit());
|
||||
if after_num.starts_with("_bug_") {
|
||||
|
||||
@@ -889,6 +889,23 @@ impl AgentPool {
|
||||
};
|
||||
|
||||
if coverage_passed {
|
||||
// Spikes skip merge — they stay in 3_qa/ for human review.
|
||||
if super::lifecycle::item_type_from_id(story_id) == "spike" {
|
||||
// Mark the spike as held for review so auto-assign won't
|
||||
// restart QA on it.
|
||||
let qa_dir = project_root.join(".story_kit/work/3_qa");
|
||||
let spike_path = qa_dir.join(format!("{story_id}.md"));
|
||||
if let Err(e) = crate::io::story_metadata::write_review_hold(&spike_path) {
|
||||
slog_error!("[pipeline] Failed to set review_hold on '{story_id}': {e}");
|
||||
}
|
||||
slog!(
|
||||
"[pipeline] QA passed for spike '{story_id}'. \
|
||||
Stopping for human review (skipping merge). \
|
||||
Worktree preserved at: {worktree_path:?}"
|
||||
);
|
||||
// Free up the QA slot without advancing the spike.
|
||||
self.auto_assign_available_work(&project_root).await;
|
||||
} else {
|
||||
slog!(
|
||||
"[pipeline] QA passed gates and coverage for '{story_id}'. Moving to merge."
|
||||
);
|
||||
@@ -904,6 +921,7 @@ impl AgentPool {
|
||||
}
|
||||
// QA slot is now free — pick up any other unassigned work in 3_qa/.
|
||||
self.auto_assign_available_work(&project_root).await;
|
||||
}
|
||||
} else {
|
||||
slog!(
|
||||
"[pipeline] QA coverage gate failed for '{story_id}'. Restarting QA."
|
||||
@@ -1444,6 +1462,12 @@ impl AgentPool {
|
||||
}
|
||||
|
||||
for story_id in &items {
|
||||
// Items marked with review_hold (e.g. spikes after QA passes) stay
|
||||
// in their current stage for human review — don't auto-assign agents.
|
||||
if has_review_hold(project_root, stage_dir, story_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Re-acquire the lock on each iteration to see state changes
|
||||
// from previous start_agent calls in the same pass.
|
||||
let preferred_agent =
|
||||
@@ -1707,7 +1731,25 @@ impl AgentPool {
|
||||
};
|
||||
|
||||
if coverage_passed {
|
||||
if let Err(e) = super::lifecycle::move_story_to_merge(project_root, story_id) {
|
||||
// Spikes skip the merge stage — stay in 3_qa/ for human review.
|
||||
if super::lifecycle::item_type_from_id(story_id) == "spike" {
|
||||
let spike_path = project_root
|
||||
.join(".story_kit/work/3_qa")
|
||||
.join(format!("{story_id}.md"));
|
||||
if let Err(e) = crate::io::story_metadata::write_review_hold(&spike_path) {
|
||||
eprintln!(
|
||||
"[startup:reconcile] Failed to set review_hold on spike '{story_id}': {e}"
|
||||
);
|
||||
}
|
||||
eprintln!(
|
||||
"[startup:reconcile] Spike '{story_id}' passed QA — holding for human review."
|
||||
);
|
||||
let _ = progress_tx.send(ReconciliationEvent {
|
||||
story_id: story_id.clone(),
|
||||
status: "review_hold".to_string(),
|
||||
message: "Spike passed QA — waiting for human review.".to_string(),
|
||||
});
|
||||
} else if let Err(e) = super::lifecycle::move_story_to_merge(project_root, story_id) {
|
||||
eprintln!(
|
||||
"[startup:reconcile] Failed to move '{story_id}' to 4_merge/: {e}"
|
||||
);
|
||||
@@ -1922,6 +1964,24 @@ fn read_story_front_matter_agent(project_root: &Path, stage_dir: &str, story_id:
|
||||
parse_front_matter(&contents).ok()?.agent
|
||||
}
|
||||
|
||||
/// Return `true` if the story file in the given stage has `review_hold: true` in its front matter.
|
||||
fn has_review_hold(project_root: &Path, stage_dir: &str, story_id: &str) -> bool {
|
||||
use crate::io::story_metadata::parse_front_matter;
|
||||
let path = project_root
|
||||
.join(".story_kit")
|
||||
.join("work")
|
||||
.join(stage_dir)
|
||||
.join(format!("{story_id}.md"));
|
||||
let contents = match std::fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return false,
|
||||
};
|
||||
parse_front_matter(&contents)
|
||||
.ok()
|
||||
.and_then(|m| m.review_hold)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Return `true` if `agent_name` has no active (pending/running) entry in the pool.
|
||||
fn is_agent_free(agents: &HashMap<String, StoryAgent>, agent_name: &str) -> bool {
|
||||
!agents.values().any(|a| {
|
||||
@@ -4621,4 +4681,76 @@ stage = "coder"
|
||||
"story should be in 2_current/ or 3_qa/ after reconciliation"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_review_hold_returns_true_when_set() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let qa_dir = tmp.path().join(".story_kit/work/3_qa");
|
||||
std::fs::create_dir_all(&qa_dir).unwrap();
|
||||
let spike_path = qa_dir.join("10_spike_research.md");
|
||||
std::fs::write(
|
||||
&spike_path,
|
||||
"---\nname: Research spike\nreview_hold: true\n---\n# Spike\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(has_review_hold(tmp.path(), "3_qa", "10_spike_research"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_review_hold_returns_false_when_not_set() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let qa_dir = tmp.path().join(".story_kit/work/3_qa");
|
||||
std::fs::create_dir_all(&qa_dir).unwrap();
|
||||
let spike_path = qa_dir.join("10_spike_research.md");
|
||||
std::fs::write(
|
||||
&spike_path,
|
||||
"---\nname: Research spike\n---\n# Spike\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!has_review_hold(tmp.path(), "3_qa", "10_spike_research"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_review_hold_returns_false_when_file_missing() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
assert!(!has_review_hold(tmp.path(), "3_qa", "99_spike_missing"));
|
||||
}
|
||||
|
||||
/// Story 265: auto_assign_available_work must skip spikes in 3_qa/ that
|
||||
/// have review_hold: true set in their front matter.
|
||||
#[tokio::test]
|
||||
async fn auto_assign_skips_spikes_with_review_hold() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
|
||||
// Create project.toml with a QA agent.
|
||||
let sk = root.join(".story_kit");
|
||||
std::fs::create_dir_all(&sk).unwrap();
|
||||
std::fs::write(
|
||||
sk.join("project.toml"),
|
||||
"[[agents]]\nname = \"qa\"\nrole = \"qa\"\nmodel = \"test\"\nprompt = \"test\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Put a spike in 3_qa/ with review_hold: true.
|
||||
let qa_dir = root.join(".story_kit/work/3_qa");
|
||||
std::fs::create_dir_all(&qa_dir).unwrap();
|
||||
std::fs::write(
|
||||
qa_dir.join("20_spike_test.md"),
|
||||
"---\nname: Test Spike\nreview_hold: true\n---\n# Spike\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let (watcher_tx, _) = broadcast::channel::<WatcherEvent>(4);
|
||||
let pool = AgentPool::new(3001, watcher_tx);
|
||||
|
||||
pool.auto_assign_available_work(root).await;
|
||||
|
||||
// No agent should have been started for the spike.
|
||||
let agents = pool.agents.lock().unwrap();
|
||||
assert!(
|
||||
agents.is_empty(),
|
||||
"No agents should be assigned to a spike with review_hold"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ pub struct StoryMetadata {
|
||||
pub coverage_baseline: Option<String>,
|
||||
pub merge_failure: Option<String>,
|
||||
pub agent: Option<String>,
|
||||
pub review_hold: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -31,6 +32,7 @@ struct FrontMatter {
|
||||
coverage_baseline: Option<String>,
|
||||
merge_failure: Option<String>,
|
||||
agent: Option<String>,
|
||||
review_hold: Option<bool>,
|
||||
}
|
||||
|
||||
pub fn parse_front_matter(contents: &str) -> Result<StoryMetadata, StoryMetaError> {
|
||||
@@ -64,6 +66,7 @@ fn build_metadata(front: FrontMatter) -> StoryMetadata {
|
||||
coverage_baseline: front.coverage_baseline,
|
||||
merge_failure: front.merge_failure,
|
||||
agent: front.agent,
|
||||
review_hold: front.review_hold,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +101,17 @@ pub fn write_merge_failure(path: &Path, reason: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write `review_hold: true` to the YAML front matter of a story file.
|
||||
///
|
||||
/// Used to mark spikes that have passed QA and are waiting for human review.
|
||||
pub fn write_review_hold(path: &Path) -> Result<(), String> {
|
||||
let contents =
|
||||
fs::read_to_string(path).map_err(|e| format!("Failed to read story file: {e}"))?;
|
||||
let updated = set_front_matter_field(&contents, "review_hold", "true");
|
||||
fs::write(path, &updated).map_err(|e| format!("Failed to write story file: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a key from the YAML front matter of a story file on disk.
|
||||
///
|
||||
/// If front matter is present and contains the key, the line is removed.
|
||||
@@ -328,4 +342,29 @@ workflow: tdd
|
||||
let input = " - [ ] Indented item\n";
|
||||
assert_eq!(parse_unchecked_todos(input), vec!["Indented item"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_review_hold_from_front_matter() {
|
||||
let input = "---\nname: Spike\nreview_hold: true\n---\n# Spike\n";
|
||||
let meta = parse_front_matter(input).expect("front matter");
|
||||
assert_eq!(meta.review_hold, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_hold_defaults_to_none() {
|
||||
let input = "---\nname: Story\n---\n# Story\n";
|
||||
let meta = parse_front_matter(input).expect("front matter");
|
||||
assert_eq!(meta.review_hold, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_review_hold_sets_field() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("spike.md");
|
||||
std::fs::write(&path, "---\nname: My Spike\n---\n# Spike\n").unwrap();
|
||||
write_review_hold(&path).unwrap();
|
||||
let contents = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(contents.contains("review_hold: true"));
|
||||
assert!(contents.contains("name: My Spike"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user