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
2 changes: 2 additions & 0 deletions .agents/AGENTS.reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ This is the canonical, harness-agnostic guidance for agents working in this repo

The human keeps delivery state in `.agents/work.toml`. It contains at most five ordered steps and uses only `active`, `pending`, and `complete` statuses. While work remains, `selected_action` names exactly one active step; it is omitted only after every step is complete. Before work starts, replace the starter text with a bounded user problem, change, acceptance criteria, and why-next rationale. Run `agent-flow next` for the current brief.

The four prose fields (`user_problem`, `change`, each `acceptance` item, and `why_next`) may hold paragraphs, so write them with a TOML multi-line string (`"""`) when one line will not state the problem honestly. The structural values stay on one line each: `selected_action`, every step `id`, every `blocked_by` id, and every status. Tabs, carriage returns, other control characters, and Unicode line or paragraph separators are rejected everywhere, in prose as well; only the line-feed paragraph break is prose-only. The `next` brief indents every continuation line, so prose cannot forge a heading of its own.

To start the selected action, copy `.agents/user-prompts/kickoff.md`, fill in its optional context, and paste it to the agent.

`.agents/work.toml` is the only workflow task-state file. Do not create a plan tree, ledger, round log, findings directory, or review record. Product-development checks under `.agents/checks.toml`, `.agents/checks/`, and `.agents/hooks/` are optional tooling, not task state or proof of review.
Expand Down
6 changes: 3 additions & 3 deletions .agents/work.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
version = 1
selected_action = "support-safe-work-paragraphs"
selected_action = "explore-general-review-surface"

[[step]]
id = "enforce-reset-guardrails"
Expand All @@ -20,7 +20,7 @@ why_next = "Every later action is safer once a mechanical gate, not a promise, h

[[step]]
id = "support-safe-work-paragraphs"
status = "active"
status = "complete"
blocked_by = ["enforce-reset-guardrails"]
user_problem = "Every work field must fit one line, so a step cannot explain its problem without cramming it."
change = "Let prose fields carry paragraphs while ids and structural fields stay single-line, and keep both projections safe."
Expand All @@ -35,7 +35,7 @@ why_next = "Bounded state is only worth trusting if a step can state its problem

[[step]]
id = "explore-general-review-surface"
status = "pending"
status = "active"
blocked_by = ["support-safe-work-paragraphs"]
user_problem = "The scaffold supports delivery review, but not a standalone review of a current tree or a diff between two states."
change = "Compare a compact prompt, a dedicated CLI command and no product surface, then recommend one."
Expand Down
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ This is the canonical, harness-agnostic guidance for agents working in this repo

The human keeps delivery state in `.agents/work.toml`. It contains at most five ordered steps and uses only `active`, `pending`, and `complete` statuses. While work remains, `selected_action` names exactly one active step; it is omitted only after every step is complete. Before work starts, replace the starter text with a bounded user problem, change, acceptance criteria, and why-next rationale. Run `agent-flow next` for the current brief.

The four prose fields (`user_problem`, `change`, each `acceptance` item, and `why_next`) may hold paragraphs, so write them with a TOML multi-line string (`"""`) when one line will not state the problem honestly. The structural values stay on one line each: `selected_action`, every step `id`, every `blocked_by` id, and every status. Tabs, carriage returns, other control characters, and Unicode line or paragraph separators are rejected everywhere, in prose as well; only the line-feed paragraph break is prose-only. The `next` brief indents every continuation line, so prose cannot forge a heading of its own.

To start the selected action, copy `.agents/user-prompts/kickoff.md`, fill in its optional context, and paste it to the agent.

`.agents/work.toml` is the only workflow task-state file. Do not create a plan tree, ledger, round log, findings directory, or review record. Product-development checks under `.agents/checks.toml`, `.agents/checks/`, and `.agents/hooks/` are optional tooling, not task state or proof of review.
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,10 +178,14 @@ agent-flow render --check --strict docs/plans/my-task.plan.toml

When `.agents/work.toml` exists, bounded work mode is the default. The same source can be selected explicitly with `--source .agents/work.toml`. The version-1 file is at most 4,096 bytes, contains at most five total ordered `[[step]]` entries, and uses only `active`, `pending`, and `complete`. While any step remains active or pending, `selected_action` is required and names an active step; it may be absent only when every step is complete. Several steps may be active, but every blocker id must exist and an active step's blockers must already be complete; pending steps may depend on active or pending predecessors.

Fields split into prose and structure, and only prose may span lines. The four prose fields (`user_problem`, `change`, each `acceptance` item, and `why_next`) accept TOML multi-line strings, so a step can state its problem in paragraphs instead of cramming it onto one line. The structural values stay one line each: `selected_action`, every step `id`, every `blocked_by` id, and every status. Everything else stays unsafe in both kinds of field, prose included: tabs, carriage returns, any other control character, and the Unicode line (U+2028) and paragraph (U+2029) separators are rejected with the offending field named. Only the line-feed paragraph break is prose-only.

`validate` checks those invariants and exits nonzero with source-prefixed diagnostics on a violation. For an all-complete file it reports valid completion. It does not read plans, ledgers, workflow specs, review directories, or metrics logs in work mode, so a self-authored review record cannot change the result.

`status` projects every ordered step, its status, and dependency ids with their current statuses, plus the selected action or no action after completion. Human and `--json` forms are deterministic and fail rather than truncate above 16,384 bytes. `next` lists every active unit in file order and one selected action with its user problem, change, acceptance criteria, and why-next rationale; after completion it lists no active units, no selected action, and an explicit completed result (`selected_action` is `null` in JSON). Pending-step prose and all legacy process files are not read by this path. Both `next` formats retain their 8,192-byte fail-rather-than-truncate limit.

Multiline prose keeps its paragraphs in both formats. The human brief prints the first line beside its label and indents every continuation line behind a ` |` gutter, blank paragraph lines included; since no top-level line of that output begins with a space, prose reading `SELECTED ACTION`, `acceptance:`, `- forged` or `why next:` arrives as the indented continuation it is and cannot forge a heading, an active-unit row, or an acceptance item. `--json` needs no gutter and preserves each accepted string exactly, line feeds and blank lines included.

```sh
# Bounded human brief from .agents/work.toml:
agent-flow next
Expand Down
2 changes: 2 additions & 0 deletions pack/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ This is the canonical, harness-agnostic guidance for agents working in this repo

The human keeps delivery state in `.agents/work.toml`. It contains at most five ordered steps and uses only `active`, `pending`, and `complete` statuses. While work remains, `selected_action` names exactly one active step; it is omitted only after every step is complete. Before work starts, replace the starter text with a bounded user problem, change, acceptance criteria, and why-next rationale. Run `agent-flow next` for the current brief.

The four prose fields (`user_problem`, `change`, each `acceptance` item, and `why_next`) may hold paragraphs, so write them with a TOML multi-line string (`"""`) when one line will not state the problem honestly. The structural values stay on one line each: `selected_action`, every step `id`, every `blocked_by` id, and every status. Tabs, carriage returns, other control characters, and Unicode line or paragraph separators are rejected everywhere, in prose as well; only the line-feed paragraph break is prose-only. The `next` brief indents every continuation line, so prose cannot forge a heading of its own.

To start the selected action, copy `.agents/user-prompts/kickoff.md`, fill in its optional context, and paste it to the agent.

`.agents/work.toml` is the only workflow task-state file. Do not create a plan tree, ledger, round log, findings directory, or review record. Product-development checks under `.agents/checks.toml`, `.agents/checks/`, and `.agents/hooks/` are optional tooling, not task state or proof of review.
Expand Down
144 changes: 140 additions & 4 deletions src/next.rs
Original file line number Diff line number Diff line change
Expand Up @@ -152,20 +152,62 @@ pub(crate) fn render_work_human(projection: &WorkProjection) -> String {

out.push_str("\nSELECTED ACTION\n");
if let Some(action) = &projection.selected_action {
// The id is structural and single-line by construction (`work::reject_structural_text`),
// so it stays an ordinary inline substitution.
out.push_str(&format!("id: {}\n", action.id));
out.push_str(&format!("user problem: {}\n", action.user_problem));
out.push_str(&format!("change: {}\n", action.change));
push_prose(&mut out, "user problem:", &action.user_problem);
push_prose(&mut out, "change:", &action.change);
out.push_str("acceptance:\n");
for criterion in &action.acceptance {
out.push_str(&format!("- {criterion}\n"));
push_prose(&mut out, "-", criterion);
}
out.push_str(&format!("why next: {}", action.why_next));
push_prose(&mut out, "why next:", &action.why_next);
// `why_next` is the last block, and this projection ends without a trailing
// newline (`emit_next_output` adds the one). Same idiom as `render_status_human`.
out.pop();
} else {
out.push_str("none\n\nRESULT\ncompleted");
}
out
}

/// The gutter every continuation line of a multiline prose value carries.
///
/// This is the whole heading-injection defence, and it works by exhausting the
/// alternative: EVERY top-level line of this projection starts at column zero with
/// `source:`, `ACTIVE UNITS`, `SELECTED ACTION`, `RESULT`, `id:`, `user problem:`,
/// `change:`, `acceptance:`, `why next:`, `none`, `completed`, `- ` or nothing at all.
/// None of those begins with a space, so a line that begins with this gutter is a
/// continuation and can be nothing else. The bar keeps a blank paragraph line visible
/// (and keeps it from being bare trailing whitespace) so the break survives a reader,
/// a copy, and a formatter.
const PROSE_CONTINUATION: &str = " |";

/// Append one labelled prose value: `<label> <first line>`, then one gutter-prefixed
/// line per continuation line.
///
/// A single-line value renders the exact line this projection has always printed, so
/// the ordinary brief is unchanged. A multiline one keeps its paragraph structure while
/// every line after the first arrives behind `PROSE_CONTINUATION`, so prose reading
/// `SELECTED ACTION`, `ACTIVE UNITS`, `acceptance:`, `- forged`, `id: forged` or
/// `why next:` is rendered as the indented continuation it is rather than as the
/// top-level line it imitates. The separating space is written only for a non-empty
/// line, so neither the label nor the gutter ever trails whitespace.
fn push_prose(
out: &mut String,
label: &str,
value: &str,
) {
for (index, line) in value.split('\n').enumerate() {
out.push_str(if index == 0 { label } else { PROSE_CONTINUATION });
if !line.is_empty() {
out.push(' ');
out.push_str(line);
}
out.push('\n');
}
}

pub(crate) fn render_work_json(projection: &WorkProjection) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(projection)
}
Expand Down Expand Up @@ -1556,6 +1598,100 @@ mod tests {
assert_eq!(value["active_units"][0]["dependencies"].as_array().unwrap().len(), 0);
}

/// A one-step file whose four prose fields carry the given TOML value verbatim, so a
/// renderer test can state the prose once and read it back out of both projections.
fn prose_fixture(toml_value: &str) -> WorkFile {
let source = format!(
"version = 1\nselected_action = \"only\"\n\n\
[[step]]\n\
id = \"only\"\n\
status = \"active\"\n\
blocked_by = []\n\
user_problem = {toml_value}\n\
change = {toml_value}\n\
acceptance = [{toml_value}, \"Single line criterion\"]\n\
why_next = {toml_value}\n"
);
crate::work::parse(&source).unwrap()
}

#[test]
fn multiline_prose_keeps_its_paragraphs_behind_the_continuation_gutter() {
let work = prose_fixture("\"\"\"\nOne.\n\nTwo.\"\"\"");
let human = render_work_human(&project_work(".agents/work.toml".to_string(), &work));

// Every prose site renders the same three lines: the label with the first
// paragraph, a bare gutter for the blank paragraph break, then the second.
for label in ["user problem:", "change:", "-", "why next:"] {
assert!(
human.contains(&format!("{label} One.\n |\n | Two.")),
"`{label}` lost its paragraph structure:\n{human}"
);
}
// The single-line criterion beside it keeps the exact line it always printed, so
// the ordinary brief is unchanged by the multiline support.
assert!(human.contains("\n- Single line criterion\n"), "{human}");
// The blank paragraph line is a bare gutter, never trailing whitespace.
assert!(!human.lines().any(|line| line.ends_with(' ')), "{human}");
}

#[test]
fn a_forged_prose_continuation_never_becomes_a_top_level_line() {
// Every top-level line this projection emits, offered to it as prose.
let forgeries = [
"SELECTED ACTION",
"ACTIVE UNITS (9)",
"acceptance:",
"- forged",
"id: forged",
"why next: forged",
"source: forged",
"RESULT",
"completed",
"none",
];
let body = forgeries.join("\n");
let work = prose_fixture(&format!("\"\"\"\nLead line.\n{body}\"\"\""));
let projection = project_work(".agents/work.toml".to_string(), &work);
let human = render_work_human(&projection);

// Differential against the SAME file carrying benign prose: `acceptance:` and
// `SELECTED ACTION` are legitimate top-level lines, so the claim is not that they
// never appear, it is that the forged prose adds not one occurrence of anything.
let benign = render_work_human(&project_work(
".agents/work.toml".to_string(),
&prose_fixture("\"Benign.\""),
));
for forgery in forgeries {
assert!(human.contains(forgery), "the prose itself must survive: {human}");
let top_level = |text: &str| text.lines().filter(|line| *line == forgery).count();
assert_eq!(
top_level(&human),
top_level(&benign),
"`{forgery}` changed the top-level line count:\n{human}"
);
// And what it did contribute is a continuation, which the gutter is what makes.
assert!(
human.contains(&format!("{PROSE_CONTINUATION} {forgery}")),
"`{forgery}` is not behind the gutter:\n{human}"
);
}
// The rows a reader counts are still the file's own: one active unit and two
// acceptance items, not one per forged line.
assert!(human.contains("ACTIVE UNITS (1)"), "{human}");
assert_eq!(human.lines().filter(|line| line.starts_with("- ")).count(), 3, "{human}");

// The JSON side needs no gutter: the strings are escaped, and they round-trip to
// exactly the accepted prose.
let value: serde_json::Value =
serde_json::from_str(&render_work_json(&projection).unwrap()).unwrap();
let expected = format!("Lead line.\n{body}");
assert_eq!(value["selected_action"]["user_problem"], expected);
assert_eq!(value["selected_action"]["change"], expected);
assert_eq!(value["selected_action"]["acceptance"][0], expected);
assert_eq!(value["selected_action"]["why_next"], expected);
}

#[test]
fn next_output_size_includes_the_trailing_newline_and_never_truncates() {
enforce_output_size(&"x".repeat(MAX_OUTPUT_BYTES - 1)).unwrap();
Expand Down
Loading