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
320 changes: 320 additions & 0 deletions .docs/design-adf-fleet-allowlist-and-stuck-prs.md

Large diffs are not rendered by default.

284 changes: 284 additions & 0 deletions .docs/research-adf-fleet-allowlist-and-stuck-prs.md

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ cargo install terraphim-cli # Automation CLI (8 commands)
- ⚡ Lightweight (15 MB RAM, 13 MB disk)
- 🚀 Fast (<200ms operations)

**Learn more**: [Quickstart](QUICKSTART.md) | [Changelog](https://docs.terraphim.ai/src/changelog.html) | [Cross-Platform Guide](docs/archive/root/CROSS_PLATFORM_STATUS.md)
**Learn more**: [Quickstart](QUICKSTART.md) | [AI Agent Onboarding](docs/src/howto/ai-agent-onboarding-quickstart.md) | [Changelog](https://docs.terraphim.ai/src/changelog.html) | [Cross-Platform Guide](docs/archive/root/CROSS_PLATFORM_STATUS.md)

You can use it as a local search engine, configured to search for different types of content on StackOverflow, GitHub, and the local filesystem using a predefined folder, which includes your Markdown files.

Expand Down Expand Up @@ -183,6 +183,10 @@ pip install terraphim-automata

(See the [desktop README](desktop/README.md), [TUI documentation](docs/tui-usage.md), and [development setup guide](docs/src/development-setup.md) for more details.)

### AI Agent Onboarding

AI coding agents should start with the [AI Agent Onboarding Quickstart](docs/src/howto/ai-agent-onboarding-quickstart.md). It covers repository orientation, Terraphim-first search, Gitea task tracking, quality gates, release checks, and safe handoff expectations for work in this repository.

## 📚 Usage Examples

### 🦀 Rust CLI/TUI
Expand Down
14 changes: 14 additions & 0 deletions crates/terraphim_orchestrator/kg/recognised_agents.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Recognised Agent

Fleet automation accounts authorised to open auto-merge-eligible PRs. A
Gitea PR author login matching one of these `synonyms::` entries (or
starting with the `adf-` prefix, enforced separately in code) satisfies the
`require_agent_author` auto-merge gate without a human re-reviewing PR
authorship. All other quality gates (confidence, P0/P1 counts, diff size,
acceptance criteria) still apply.

To onboard a new fleet automation account, add its exact Gitea login to the
`synonyms::` line below and restart (or wait for) the orchestrator's next
allowlist reload — no code change or rebuild required.

synonyms:: claude-code, root, implementation-swarm
128 changes: 128 additions & 0 deletions crates/terraphim_orchestrator/src/agent_allowlist_kg.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
//! Loads the `recognised_agent` KG concept
//! (`crates/terraphim_orchestrator/kg/recognised_agents.md`) into the set of
//! fleet automation logins consumed by [`crate::pr_review::author_is_agent`].
//!
//! Follows the project-wide KG markdown convention (see root `CLAUDE.md`
//! "Extending Knowledge Graph"): a `synonyms::` line lists the recognised
//! terms — here, exact-match Gitea PR author logins. Operators extend the
//! allowlist by editing that line; no rebuild is required because the
//! deployed copy under `/opt/ai-dark-factory/kg/recognised_agents.md` (or
//! the path in `ADF_RECOGNISED_AGENTS_KG`) is re-read from disk, falling
//! back to the binary's embedded copy if the file is missing.
//!
//! Parsing is a pure function ([`parse_recognised_agents`]) so it is testable
//! without I/O; only [`load_recognised_agents`] touches the filesystem.

use std::collections::BTreeSet;
use std::path::Path;

/// The `recognised_agents.md` shipped in the repo, embedded at compile time
/// as the fallback used when no on-disk KG file is found.
const DEFAULT_KG_MARKDOWN: &str = include_str!("../kg/recognised_agents.md");

/// Env var pointing at an on-disk override of `recognised_agents.md`
/// (e.g. `/opt/ai-dark-factory/kg/recognised_agents.md` in production, so
/// operators can extend the allowlist without redeploying the orchestrator
/// binary).
const KG_PATH_ENV_VAR: &str = "ADF_RECOGNISED_AGENTS_KG";

/// Default on-disk path checked when `ADF_RECOGNISED_AGENTS_KG` is unset,
/// relative to the orchestrator's working directory.
const DEFAULT_KG_RELATIVE_PATH: &str = "kg/recognised_agents.md";

/// Parse a `synonyms::` line (case-insensitive key, comma-separated values)
/// out of KG markdown, returning the recognised logins verbatim (trimmed,
/// case-preserved — Gitea logins are case-sensitive).
///
/// Multiple `synonyms::` lines are merged. Lines that don't match the
/// directive are ignored, matching the tolerant parsing style used
/// elsewhere for this KG format (see `kg_router.rs`).
pub fn parse_recognised_agents(markdown: &str) -> BTreeSet<String> {
markdown
.lines()
.filter_map(|line| {
let trimmed = line.trim();
let lower = trimmed.to_ascii_lowercase();
lower
.starts_with("synonyms::")
.then(|| trimmed["synonyms::".len()..].to_string())
})
.flat_map(|rest| {
rest.split(',')
.map(|s| s.trim().to_string())
.collect::<Vec<_>>()
})
.filter(|s| !s.is_empty())
.collect()
}

/// Load the recognised-agent allowlist from disk, falling back to the
/// embedded default when the file is absent or unreadable.
///
/// Path resolution: `ADF_RECOGNISED_AGENTS_KG` env var if set, else
/// `kg/recognised_agents.md` relative to the current working directory.
pub fn load_recognised_agents() -> BTreeSet<String> {
let path =
std::env::var(KG_PATH_ENV_VAR).unwrap_or_else(|_| DEFAULT_KG_RELATIVE_PATH.to_string());
let markdown = std::fs::read_to_string(Path::new(&path))
.unwrap_or_else(|_| DEFAULT_KG_MARKDOWN.to_string());
parse_recognised_agents(&markdown)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn parses_embedded_default() {
let logins = parse_recognised_agents(DEFAULT_KG_MARKDOWN);
assert!(logins.contains("claude-code"));
assert!(logins.contains("root"));
assert!(logins.contains("implementation-swarm"));
}

#[test]
fn parses_multiple_synonyms_lines() {
let markdown = "# Concept\n\nsynonyms:: alpha, beta\nsynonyms:: gamma\n";
let logins = parse_recognised_agents(markdown);
assert_eq!(
logins,
["alpha", "beta", "gamma"]
.into_iter()
.map(String::from)
.collect::<BTreeSet<_>>()
);
}

#[test]
fn ignores_unrelated_lines_and_trims_whitespace() {
let markdown = "# Title\n\nSome prose.\n\nsynonyms:: claude-code , root \n";
let logins = parse_recognised_agents(markdown);
assert_eq!(
logins,
["claude-code", "root"]
.into_iter()
.map(String::from)
.collect::<BTreeSet<_>>()
);
}

#[test]
fn returns_empty_set_when_no_synonyms_line_present() {
assert!(parse_recognised_agents("# Title\n\nJust prose, no directive.\n").is_empty());
}

#[test]
fn load_falls_back_to_embedded_default_when_env_path_missing() {
// SAFETY: test-only env mutation, no concurrent readers of this var
// in this crate's test suite.
unsafe {
std::env::set_var(KG_PATH_ENV_VAR, "/nonexistent/path/recognised_agents.md");
}
let logins = load_recognised_agents();
assert!(logins.contains("implementation-swarm"));
unsafe {
std::env::remove_var(KG_PATH_ENV_VAR);
}
}
}
9 changes: 6 additions & 3 deletions crates/terraphim_orchestrator/src/auto_merge_impl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ use crate::dispatcher::DispatchTask;
#[cfg(feature = "quickwit")]
use crate::quickwit;
use crate::{
config, dispatcher, post_merge_gate, pr_gate, pr_poller, pr_review, truncate_for_issue,
AgentOrchestrator, OrchestratorError,
agent_allowlist_kg, config, dispatcher, post_merge_gate, pr_gate, pr_poller, pr_review,
truncate_for_issue, AgentOrchestrator, OrchestratorError,
};

impl AgentOrchestrator {
Expand Down Expand Up @@ -58,7 +58,10 @@ impl AgentOrchestrator {
return Ok(());
}

let criteria = pr_review::AutoMergeCriteria::default();
let criteria = pr_review::AutoMergeCriteria {
recognised_agent_logins: agent_allowlist_kg::load_recognised_agents(),
..pr_review::AutoMergeCriteria::default()
};

for (project_id, gitea_cfg) in targets {
let tracker_cfg = terraphim_tracker::GiteaConfig {
Expand Down
1 change: 1 addition & 0 deletions crates/terraphim_orchestrator/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
//! ```
pub mod adf_commands;
pub mod agent_allowlist_kg;
pub mod agent_registry;
pub mod agent_run_command;
pub mod agent_run_record;
Expand Down
13 changes: 9 additions & 4 deletions crates/terraphim_orchestrator/src/pr_poller.rs
Original file line number Diff line number Diff line change
Expand Up @@ -320,9 +320,14 @@ pub fn evaluate_pr_gates(
project_id: &str,
criteria: &AutoMergeCriteria,
) -> EvaluationOutcome {
if criteria.require_agent_author && !author_is_agent(&pr.author_login) {
if criteria.require_agent_author
&& !author_is_agent(&pr.author_login, &criteria.recognised_agent_logins)
{
return EvaluationOutcome::HumanReviewNeeded {
reason: agent_author_rejection_reason(&pr.author_login),
reason: agent_author_rejection_reason(
&pr.author_login,
&criteria.recognised_agent_logins,
),
};
}
if pr.diff_loc > criteria.max_diff_loc {
Expand Down Expand Up @@ -725,8 +730,8 @@ mod tests {
"reason must mention allowlisting, got: {reason}"
);
assert!(
reason.contains("orchestrator.toml"),
"reason must point to the fleet config file, got: {reason}"
reason.contains("kg/recognised_agents.md"),
"reason must point to the KG allowlist file, got: {reason}"
);
}

Expand Down
86 changes: 60 additions & 26 deletions crates/terraphim_orchestrator/src/pr_review.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,17 @@ pub struct AutoMergeCriteria {
pub require_all_criteria: bool,
pub max_diff_loc: u32,
pub require_agent_author: bool,
/// Exact-match logins recognised as fleet automation accounts, in
/// addition to the `adf-` prefix rule applied by [`author_is_agent`].
///
/// Populated from the `recognised_agent` KG concept
/// (`crates/terraphim_orchestrator/kg/recognised_agents.md`) by
/// [`crate::agent_allowlist_kg::load_recognised_agents`] at orchestrator
/// startup; operators extend the allowlist by editing that markdown
/// file's `synonyms::` line, no rebuild required. The hardcoded default
/// here is a pure, I/O-free fallback so this module and its tests never
/// touch the filesystem.
pub recognised_agent_logins: std::collections::BTreeSet<String>,
}

impl Default for AutoMergeCriteria {
Expand All @@ -68,6 +79,10 @@ impl Default for AutoMergeCriteria {
require_all_criteria: true,
max_diff_loc: 500,
require_agent_author: true,
recognised_agent_logins: ["claude-code", "root", "implementation-swarm"]
.into_iter()
.map(String::from)
.collect(),
}
}
}
Expand Down Expand Up @@ -155,7 +170,7 @@ pub fn parse_verdict(body: &str, comment_id: u64) -> Result<ReviewVerdict, Verdi
/// 3. `verdict.p1_count <= criteria.max_p1`
/// 4. `verdict.all_criteria_met || !criteria.require_all_criteria`
/// 5. `pr.diff_loc <= criteria.max_diff_loc`
/// 6. `!criteria.require_agent_author || author_is_agent(&pr.author_login)`
/// 6. `!criteria.require_agent_author || author_is_agent(&pr.author_login, &criteria.recognised_agent_logins)`
///
/// When a gate fails the returned [`AutoMergeDecision::HumanReviewNeeded`]
/// carries a short reason string suitable for posting back to the PR.
Expand Down Expand Up @@ -193,9 +208,12 @@ pub fn evaluate(
pr.diff_loc, criteria.max_diff_loc
));
}
if criteria.require_agent_author && !author_is_agent(&pr.author_login) {
if criteria.require_agent_author
&& !author_is_agent(&pr.author_login, &criteria.recognised_agent_logins)
{
return AutoMergeDecision::HumanReviewNeeded(agent_author_rejection_reason(
&pr.author_login,
&criteria.recognised_agent_logins,
));
}
AutoMergeDecision::Merge
Expand All @@ -211,27 +229,40 @@ pub fn evaluate(
/// the twin gates can never drift out of sync.
///
/// The allowlist policy itself is [`author_is_agent`]: a login is recognised
/// when it is exactly `claude-code` or `root`, or starts with the `adf-` fleet
/// prefix. To allowlist a new automation account, add it to the orchestrator
/// fleet config (an `[[agents]]` entry in `orchestrator.toml`).
pub fn agent_author_rejection_reason(login: &str) -> String {
/// when it appears (verbatim) in `recognised_logins`, or starts with the
/// `adf-` fleet prefix. To allowlist a new automation account, add its login
/// to the `synonyms::` line in
/// `crates/terraphim_orchestrator/kg/recognised_agents.md` — no rebuild
/// required, the orchestrator re-reads it via
/// [`crate::agent_allowlist_kg::load_recognised_agents`].
pub fn agent_author_rejection_reason(
login: &str,
recognised_logins: &std::collections::BTreeSet<String>,
) -> String {
let known = recognised_logins
.iter()
.map(String::as_str)
.collect::<Vec<_>>()
.join(", ");
format!(
"author `{login}` is not a recognised agent; human-authored PRs require manual merge. \
To allowlist, add the login to the orchestrator fleet config \
(`[[agents]]` in `orchestrator.toml`); recognised logins are exactly \
`claude-code`, `root`, or any `adf-` prefix."
To allowlist, add the login to the `synonyms::` line in \
`crates/terraphim_orchestrator/kg/recognised_agents.md`; recognised logins are \
currently [{known}] or any `adf-` prefix."
)
}

/// Return `true` when `login` belongs to an automation account authorised to
/// open auto-merge-eligible PRs.
///
/// Policy: the login exactly matches `claude-code` or `root`, or starts with
/// `adf-` (the ADF fleet agent prefix). Anything else — including human
/// maintainers, bots from other tenants, and Renovate/Dependabot — is
/// considered non-agent for auto-merge purposes.
pub fn author_is_agent(login: &str) -> bool {
matches!(login, "claude-code" | "root") || login.starts_with("adf-")
/// Policy: the login exactly matches an entry in `recognised_logins` (KG-
/// driven allowlist, see [`crate::agent_allowlist_kg`]), or starts with
/// `adf-` (the ADF fleet agent prefix, a structural convention rather than a
/// KG concept). Anything else — including human maintainers, bots from other
/// tenants, and Renovate/Dependabot — is considered non-agent for auto-merge
/// purposes.
pub fn author_is_agent(login: &str, recognised_logins: &std::collections::BTreeSet<String>) -> bool {
recognised_logins.contains(login) || login.starts_with("adf-")
}

/// Strip HTML attributes from `<h3 ...>` tags so that formatting variations
Expand Down Expand Up @@ -368,18 +399,21 @@ mod tests {

#[test]
fn author_is_agent_policy() {
assert!(author_is_agent("claude-code"));
assert!(author_is_agent("root"));
assert!(author_is_agent("adf-fleet"));
assert!(author_is_agent("adf-reviewer"));
assert!(!author_is_agent("alex"));
assert!(!author_is_agent("dependabot[bot]"));
assert!(!author_is_agent("renovate[bot]"));
let recognised = AutoMergeCriteria::default().recognised_agent_logins;
assert!(author_is_agent("claude-code", &recognised));
assert!(author_is_agent("root", &recognised));
assert!(author_is_agent("implementation-swarm", &recognised));
assert!(author_is_agent("adf-fleet", &recognised));
assert!(author_is_agent("adf-reviewer", &recognised));
assert!(!author_is_agent("alex", &recognised));
assert!(!author_is_agent("dependabot[bot]", &recognised));
assert!(!author_is_agent("renovate[bot]", &recognised));
}

#[test]
fn agent_author_rejection_reason_includes_login_and_allowlist_hint() {
let reason = agent_author_rejection_reason("dependabot[bot]");
let recognised = AutoMergeCriteria::default().recognised_agent_logins;
let reason = agent_author_rejection_reason("dependabot[bot]", &recognised);
// AC: the rejection message contains the exact author login.
assert!(
reason.contains("author `dependabot[bot]`"),
Expand All @@ -391,8 +425,8 @@ mod tests {
"reason must mention allowlisting, got: {reason}"
);
assert!(
reason.contains("orchestrator.toml"),
"reason must point to the fleet config file, got: {reason}"
reason.contains("kg/recognised_agents.md"),
"reason must point to the KG allowlist file, got: {reason}"
);
// Hint must mirror the real author_is_agent() policy so operators are
// not misled about which logins are already accepted.
Expand Down Expand Up @@ -429,7 +463,7 @@ mod tests {
};
assert!(reason.contains("renovate[bot]"));
assert!(reason.contains("allowlist"));
assert!(reason.contains("orchestrator.toml"));
assert!(reason.contains("kg/recognised_agents.md"));
}

#[test]
Expand Down
6 changes: 5 additions & 1 deletion crates/terraphim_workspace/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -676,7 +676,11 @@ mod tests {
};
let mgr = WorkspaceManager::new(&config).unwrap();

let valid = tmp.path().join("MT-42");
// Join against the manager's canonicalised root, not `tmp.path()`
// directly: on macOS `TMPDIR` resolves through a `/var` -> `/private/var`
// symlink, so a path built from the raw `tmp.path()` would fail
// `starts_with(&mgr.root)` even though it is genuinely inside the root.
let valid = mgr.root.join("MT-42");
assert!(mgr.validate_path(&valid, "MT-42").is_ok());
}
}
1 change: 1 addition & 0 deletions docs/src/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@

## How-Tos

- [AI Agent Onboarding Quickstart](./howto/ai-agent-onboarding-quickstart.md)
- [Learning Capture for Claude Code](./howto/learning-capture-claude-code.md)
- [Learning Capture for opencode](./howto/learning-capture-opencode.md)
- [Personal Assistant Role (JMAP + Obsidian)](./howto/personal-assistant-role.md)
Expand Down
Loading
Loading