Skip to content
This repository was archived by the owner on Apr 11, 2026. It is now read-only.

Commit 17aebf1

Browse files
committed
feat(flowctl): add .flow/outputs/ narrative handoff layer (fn-20.2)
Adds a lightweight per-task narrative dump at `.flow/outputs/<task-id>.md` that workers write in new Phase 5c and the next worker auto-reads during Phase 1 re-anchor. Gated on a new `outputs.enabled` config key (default true), fully independent from `memory.enabled` — outputs is a narrative handoff layer, not part of the verified memory system. - flowctl-core: new `OutputEntry` protocol type (convention #8) - flowctl-service: new `OutputsStore` (file-system native, no libSQL) - flowctl-cli: new `flowctl outputs write|list|show` commands - Phase 5c `outputs_dump` added to worker sequence dynamically based on `outputs.enabled` config; worker.md Phase 1 extended to list+show prior outputs (limit 3) and Phase 5c added between Phase 5 and Phase 5b - `done_task()` in lifecycle.rs intentionally unchanged — worker owns the dump explicitly in Phase 5c, keeping runtime agnostic of narrative shape - Missing `## Surprises`/`## Decisions` sections allowed (pitfall #2)
1 parent 8cd918b commit 17aebf1

11 files changed

Lines changed: 498 additions & 6 deletions

File tree

agents/worker.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,17 @@ This returns a compact index (~50 tokens/entry). If you see relevant entries, fe
156156
```
157157
Only fetch full content for entries relevant to your task's technology/domain.
158158

159+
**Check prior outputs** (if `outputs.enabled` is true, default):
160+
```bash
161+
<FLOWCTL> config get outputs.enabled --json
162+
<FLOWCTL> outputs list --epic <EPIC_ID> --limit 3 --json
163+
```
164+
For each entry returned, fetch its content and include verbatim in your context:
165+
```bash
166+
<FLOWCTL> outputs show <prior-task-id>
167+
```
168+
These are lightweight narrative handoffs from earlier tasks in this epic — read them to understand what upstream work surprised the previous worker, what decisions they made, and what gotchas to watch for. Skip gracefully if the list is empty (new epic) or if `outputs.enabled` is false.
169+
159170
Parse the spec carefully. Identify:
160171
- Acceptance criteria
161172
- Dependencies on other tasks
@@ -474,6 +485,43 @@ Status MUST be `done`. If not:
474485
4. **Do NOT send "Task complete" message until status is confirmed `done`**
475486
<!-- /section:core -->
476487

488+
<!-- section:outputs -->
489+
## Phase 5c: Outputs Dump (if outputs.enabled)
490+
491+
**Skip if `outputs.enabled` is false.** This is gated on its own config key — independent from `memory.enabled`. Outputs are a lightweight narrative handoff layer (plain markdown, no verification), separate from the verified memory system.
492+
493+
After completing the task, write a ≤200-word narrative dump to `.flow/outputs/<TASK_ID>.md` for the next worker in this epic:
494+
495+
```bash
496+
# Check if outputs is enabled (default: true)
497+
OUTPUTS_ENABLED=$(<FLOWCTL> config get outputs.enabled --json 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('value', True))" 2>/dev/null || echo "True")
498+
499+
if [ "$OUTPUTS_ENABLED" = "True" ] || [ "$OUTPUTS_ENABLED" = "true" ]; then
500+
<FLOWCTL> outputs write <TASK_ID> --file - << 'EOF'
501+
## Summary
502+
503+
<1–3 sentence summary of what you implemented, ≤200 words total>
504+
505+
## Surprises
506+
507+
- <Thing that surprised you during implementation, or "None">
508+
- <Another gotcha, if any>
509+
510+
## Decisions
511+
512+
- <Key design/architecture decision + rationale>
513+
- <Another decision, if any>
514+
EOF
515+
fi
516+
```
517+
518+
**Rules:**
519+
- All three sections are allowed to be missing or empty — downstream readers handle that gracefully
520+
- Focus on narrative handoff: what would help the next worker, not comprehensive docs
521+
- Don't repeat spec content — only things you learned while working
522+
- This is narrative handoff, NOT verified memory. Save verified pitfalls/conventions in Phase 5b.
523+
<!-- /section:outputs -->
524+
477525
<!-- section:memory -->
478526
## Phase 5b: Memory Auto-Save (if memory enabled)
479527

flowctl/crates/flowctl-cli/src/commands/admin/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ use crate::commands::helpers::get_flow_dir;
1515
fn get_default_config() -> serde_json::Value {
1616
json!({
1717
"memory": {"enabled": true},
18+
"outputs": {"enabled": true},
1819
"planSync": {"enabled": true, "crossEpic": false},
1920
"review": {"backend": null},
2021
"scouts": {"github": false},

flowctl/crates/flowctl-cli/src/commands/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ pub mod epic;
1010
pub mod gap;
1111
pub mod hook;
1212
pub mod memory;
13+
pub mod outputs;
1314
pub mod query;
1415
pub mod ralph;
1516
pub mod rp;
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
//! Outputs commands: write, list, show.
2+
//!
3+
//! Thin CLI wrapper over `flowctl_service::outputs::OutputsStore`. Provides
4+
//! a lightweight narrative handoff layer at `.flow/outputs/<task-id>.md` that
5+
//! workers populate in Phase 5c and read during Phase 1 re-anchor.
6+
7+
use std::fs;
8+
use std::io::Read;
9+
10+
use clap::Subcommand;
11+
use serde_json::json;
12+
13+
use flowctl_core::id::is_task_id;
14+
use flowctl_service::outputs::OutputsStore;
15+
16+
use crate::output::{error_exit, json_output};
17+
18+
use super::helpers::get_flow_dir;
19+
20+
#[derive(Subcommand, Debug)]
21+
pub enum OutputsCmd {
22+
/// Write output markdown for a task (from file or stdin via '-').
23+
Write {
24+
/// Task ID (e.g. fn-1.3).
25+
task_id: String,
26+
/// Path to content file, or '-' for stdin.
27+
#[arg(long)]
28+
file: String,
29+
},
30+
/// List prior outputs for an epic, newest-first.
31+
List {
32+
/// Epic ID.
33+
#[arg(long)]
34+
epic: String,
35+
/// Max entries to return.
36+
#[arg(long)]
37+
limit: Option<usize>,
38+
},
39+
/// Print the full markdown content for a task's output file.
40+
Show {
41+
/// Task ID (e.g. fn-1.3).
42+
task_id: String,
43+
},
44+
}
45+
46+
pub fn dispatch(cmd: &OutputsCmd, json: bool) {
47+
match cmd {
48+
OutputsCmd::Write { task_id, file } => cmd_outputs_write(json, task_id, file),
49+
OutputsCmd::List { epic, limit } => cmd_outputs_list(json, epic, *limit),
50+
OutputsCmd::Show { task_id } => cmd_outputs_show(json, task_id),
51+
}
52+
}
53+
54+
/// Open the store, exiting cleanly if `.flow/` is missing.
55+
fn open_store(json: bool) -> OutputsStore {
56+
let flow_dir = get_flow_dir();
57+
if !flow_dir.exists() {
58+
if json {
59+
json_output(json!({"error": ".flow/ does not exist. Run 'flowctl init' first."}));
60+
std::process::exit(1);
61+
} else {
62+
error_exit(".flow/ does not exist. Run 'flowctl init' first.");
63+
}
64+
}
65+
match OutputsStore::new(&flow_dir) {
66+
Ok(store) => store,
67+
Err(e) => {
68+
if json {
69+
json_output(json!({"error": format!("failed to open outputs store: {e}")}));
70+
std::process::exit(1);
71+
} else {
72+
error_exit(&format!("failed to open outputs store: {e}"));
73+
}
74+
}
75+
}
76+
}
77+
78+
fn cmd_outputs_write(json: bool, task_id: &str, file: &str) {
79+
if !is_task_id(task_id) {
80+
if json {
81+
json_output(json!({"error": format!("Invalid task ID: {task_id}")}));
82+
std::process::exit(1);
83+
} else {
84+
error_exit(&format!(
85+
"Invalid task ID: {task_id}. Expected format: fn-N.M or fn-N-slug.M"
86+
));
87+
}
88+
}
89+
90+
// Read content from file or stdin.
91+
let content = if file == "-" {
92+
let mut buf = String::new();
93+
if let Err(e) = std::io::stdin().read_to_string(&mut buf) {
94+
error_exit(&format!("failed to read stdin: {e}"));
95+
}
96+
buf
97+
} else {
98+
match fs::read_to_string(file) {
99+
Ok(c) => c,
100+
Err(e) => error_exit(&format!("failed to read {file}: {e}")),
101+
}
102+
};
103+
104+
let store = open_store(json);
105+
match store.write(task_id, &content) {
106+
Ok(path) => {
107+
if json {
108+
json_output(json!({
109+
"task_id": task_id,
110+
"path": path.to_string_lossy(),
111+
"bytes": content.len(),
112+
}));
113+
} else {
114+
println!("Wrote {} ({} bytes)", path.display(), content.len());
115+
}
116+
}
117+
Err(e) => {
118+
if json {
119+
json_output(json!({"error": format!("write failed: {e}")}));
120+
std::process::exit(1);
121+
} else {
122+
error_exit(&format!("write failed: {e}"));
123+
}
124+
}
125+
}
126+
}
127+
128+
fn cmd_outputs_list(json: bool, epic_id: &str, limit: Option<usize>) {
129+
let store = open_store(json);
130+
match store.list_for_epic(epic_id, limit) {
131+
Ok(entries) => {
132+
if json {
133+
let arr: Vec<_> = entries
134+
.iter()
135+
.map(|e| {
136+
json!({
137+
"task_id": e.task_id,
138+
"path": e.path.to_string_lossy(),
139+
"mtime": e.mtime,
140+
})
141+
})
142+
.collect();
143+
json_output(json!({"entries": arr, "count": entries.len()}));
144+
} else if entries.is_empty() {
145+
println!("(no outputs for epic {epic_id})");
146+
} else {
147+
for e in &entries {
148+
println!("{}\t{}", e.task_id, e.path.display());
149+
}
150+
}
151+
}
152+
Err(e) => {
153+
if json {
154+
json_output(json!({"error": format!("list failed: {e}")}));
155+
std::process::exit(1);
156+
} else {
157+
error_exit(&format!("list failed: {e}"));
158+
}
159+
}
160+
}
161+
}
162+
163+
fn cmd_outputs_show(json: bool, task_id: &str) {
164+
if !is_task_id(task_id) {
165+
if json {
166+
json_output(json!({"error": format!("Invalid task ID: {task_id}")}));
167+
std::process::exit(1);
168+
} else {
169+
error_exit(&format!(
170+
"Invalid task ID: {task_id}. Expected format: fn-N.M or fn-N-slug.M"
171+
));
172+
}
173+
}
174+
175+
let store = open_store(json);
176+
match store.read(task_id) {
177+
Ok(content) => {
178+
if json {
179+
json_output(json!({
180+
"task_id": task_id,
181+
"content": content,
182+
}));
183+
} else {
184+
print!("{content}");
185+
}
186+
}
187+
Err(e) => {
188+
if json {
189+
json_output(json!({"error": format!("read failed: {e}")}));
190+
std::process::exit(1);
191+
} else {
192+
error_exit(&format!("read failed: {e}"));
193+
}
194+
}
195+
}
196+
}

flowctl/crates/flowctl-cli/src/commands/workflow/phase.rs

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -61,14 +61,16 @@ const PHASE_DEFS: &[PhaseDef] = &[
6161
PhaseDef { id: "3", title: "Commit", done_condition: "Changes committed with conventional commit message" },
6262
PhaseDef { id: "4", title: "Review", done_condition: "SHIP verdict received from reviewer" },
6363
PhaseDef { id: "5", title: "Complete", done_condition: "flowctl done called and task status is done" },
64+
PhaseDef { id: "5c", title: "Outputs Dump", done_condition: "Narrative summary written to .flow/outputs/<task-id>.md" },
6465
PhaseDef { id: "5b", title: "Memory Auto-Save", done_condition: "Non-obvious lessons saved to memory (if any)" },
6566
PhaseDef { id: "6", title: "Return", done_condition: "Summary returned to main conversation" },
6667
];
6768

6869
/// Canonical ordering of all phases — used to merge sequences.
69-
const CANONICAL_ORDER: &[&str] = &["0", "1", "2a", "2", "2.5", "3", "4", "5", "5b", "6"];
70+
const CANONICAL_ORDER: &[&str] = &["0", "1", "2a", "2", "2.5", "3", "4", "5", "5c", "5b", "6"];
7071

7172
/// Default phase sequence (Worktree + Teams, always includes Phase 0).
73+
/// Phase 5c is appended when `outputs.enabled` is true (default).
7274
const PHASE_SEQ_DEFAULT: &[&str] = &["0", "1", "2", "2.5", "3", "5", "5b", "6"];
7375
const PHASE_SEQ_TDD: &[&str] = &["0", "1", "2a", "2", "2.5", "3", "5", "5b", "6"];
7476
const PHASE_SEQ_REVIEW: &[&str] = &["0", "1", "2", "2.5", "3", "4", "5", "5b", "6"];
@@ -77,12 +79,31 @@ fn get_phase_def(phase_id: &str) -> Option<&'static PhaseDef> {
7779
PHASE_DEFS.iter().find(|p| p.id == phase_id)
7880
}
7981

80-
/// Build the phase sequence based on mode flags.
81-
fn build_phase_sequence(tdd: bool, review: bool) -> Vec<&'static str> {
82-
if !tdd && !review {
83-
return PHASE_SEQ_DEFAULT.to_vec();
82+
/// Read `outputs.enabled` from .flow/config.json. Default: true.
83+
fn is_outputs_enabled() -> bool {
84+
use flowctl_core::types::{CONFIG_FILE, FLOW_DIR};
85+
let cfg_path = std::env::current_dir()
86+
.unwrap_or_else(|_| std::path::PathBuf::from("."))
87+
.join(FLOW_DIR)
88+
.join(CONFIG_FILE);
89+
if !cfg_path.exists() {
90+
return true;
91+
}
92+
match std::fs::read_to_string(&cfg_path) {
93+
Ok(content) => {
94+
let cfg: serde_json::Value =
95+
serde_json::from_str(&content).unwrap_or(serde_json::json!({}));
96+
cfg.get("outputs")
97+
.and_then(|m| m.get("enabled"))
98+
.and_then(|v| v.as_bool())
99+
.unwrap_or(true)
100+
}
101+
Err(_) => true,
84102
}
103+
}
85104

105+
/// Build the phase sequence based on mode flags.
106+
fn build_phase_sequence(tdd: bool, review: bool) -> Vec<&'static str> {
86107
let mut phases = HashSet::new();
87108
for p in PHASE_SEQ_DEFAULT {
88109
phases.insert(*p);
@@ -97,6 +118,9 @@ fn build_phase_sequence(tdd: bool, review: bool) -> Vec<&'static str> {
97118
phases.insert(*p);
98119
}
99120
}
121+
if is_outputs_enabled() {
122+
phases.insert("5c");
123+
}
100124
CANONICAL_ORDER.iter().copied().filter(|p| phases.contains(p)).collect()
101125
}
102126

flowctl/crates/flowctl-cli/src/main.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ use commands::{
1919
gap::GapCmd,
2020
hook::HookCmd,
2121
memory::MemoryCmd,
22+
outputs::OutputsCmd,
2223
query,
2324
ralph::RalphCmd,
2425
rp::RpCmd,
@@ -184,6 +185,11 @@ enum Commands {
184185
#[command(subcommand)]
185186
cmd: MemoryCmd,
186187
},
188+
/// Outputs commands (narrative handoff between tasks).
189+
Outputs {
190+
#[command(subcommand)]
191+
cmd: OutputsCmd,
192+
},
187193
/// Checkpoint commands.
188194
Checkpoint {
189195
#[command(subcommand)]
@@ -478,6 +484,7 @@ fn main() {
478484
Commands::Dep { cmd } => commands::dep::dispatch(&cmd, json),
479485
Commands::Gap { cmd } => commands::gap::dispatch(&cmd, json),
480486
Commands::Memory { cmd } => commands::memory::dispatch(&cmd, json),
487+
Commands::Outputs { cmd } => commands::outputs::dispatch(&cmd, json),
481488
Commands::Checkpoint { cmd } => commands::checkpoint::dispatch(&cmd, json),
482489
Commands::Stack { cmd } => commands::stack::dispatch(&cmd, json),
483490
Commands::Invariants { cmd } => commands::stack::dispatch_invariants(&cmd, json),

flowctl/crates/flowctl-core/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ pub mod dag;
88
pub mod error;
99
pub mod frontmatter;
1010
pub mod id;
11+
pub mod outputs;
1112
pub mod review_protocol;
1213
pub mod state_machine;
1314
pub mod task_profile;
@@ -17,5 +18,6 @@ pub mod types;
1718
pub use dag::TaskDag;
1819
pub use error::CoreError;
1920
pub use id::{parse_id, slugify, EpicId, ParsedId, TaskId};
21+
pub use outputs::OutputEntry;
2022
pub use state_machine::{Status, Transition, TransitionError};
2123
pub use types::{Epic, Evidence, Phase, Task};

0 commit comments

Comments
 (0)