Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
638 lines
26 KiB
Rust
638 lines
26 KiB
Rust
//! Heuristic stop-detector for premature "give up" turn endings.
|
|
//!
|
|
//! The model is judged to be bailing out when the LAST non-empty
|
|
//! paragraph of its turn-final text starts with one of the patterns
|
|
//! commonly used as a bail / hand-off / verdict signal.
|
|
//! On a hit, `maybe_queue_goal_continuation` (reached on the success /
|
|
//! continuation path) renders the bail-specific continuation nudge
|
|
//! instead of the generic one and the harness emits
|
|
//! `Event::GoalPrematureStopDetected { pattern }` tagged with the
|
|
//! matched pattern label so dashboards can audit precision / recall of
|
|
//! the regex panel.
|
|
//!
|
|
//! Each regex is locked to a source-string constant in
|
|
//! [`STOP_REGEX_SOURCES`] by a regression test (asserting each
|
|
//! `Regex::as_str()` matches) so a later refactor cannot silently
|
|
//! swap a pattern out. Two patterns are expressed in two stages
|
|
//! rather than as a single regex:
|
|
//!
|
|
//! 1. [`CHECK_BACK_LATER`] — a single-regex form would need a negative
|
|
//! lookahead `(?!your?\b)` that the `regex` crate does not
|
|
//! support; we implement the same semantics in two stages
|
|
//! (broad first-stage regex + `your`/`you` post-filter in
|
|
//! `check_back_later_matches`).
|
|
//! 2. `STOPPING_HERE` — the base trailer set
|
|
//! `(?:\.|$| \u2014| -| until| pending| since| because)` is
|
|
//! widened to include `,`, `;`, and ` for ` so naturally-occurring
|
|
//! sign-offs like "Stopping here for now.", "Stopping here, will
|
|
//! come back later.", and "Paused here; review needed." still
|
|
//! fire. The widening is bounded by a non-letter boundary in the
|
|
//! tests so in-word matches like "Stopping hereafter" stay
|
|
//! rejected.
|
|
//!
|
|
//! Intentionally omitted from this panel: a broad catch-all
|
|
//! "continuation deferral" pattern (`\b(?:once|when|after|until|
|
|
//! as soon as)\b…`). Stand-alone it fires on routine work narration
|
|
//! ("Once the test settles I'll iterate") and the resulting false-
|
|
//! positive rate dwarfs the bail signal we care about; the more
|
|
//! specific patterns below already cover the bail surface.
|
|
//!
|
|
//! Regexes are anchored with `^` so the marker must start a line; in-
|
|
//! prose mentions like "I can't continue without your input" mid-
|
|
//! sentence are intentionally ignored.
|
|
|
|
use regex::Regex;
|
|
use std::sync::LazyLock;
|
|
|
|
/// Stable label for `Event::GoalPrematureStopDetected.pattern` when
|
|
/// the surrender-phrasing regex fires.
|
|
pub(crate) const PATTERN_UNABLE_TO_PROCEED: &str = "unable_to_proceed";
|
|
/// Stable label for "giving up" / "task not actionable".
|
|
pub(crate) const PATTERN_GIVING_UP: &str = "giving_up";
|
|
/// Stable label for "Stopping here" / "Parked the branch" /
|
|
/// "Paused here" family.
|
|
pub(crate) const PATTERN_STOPPING_HERE: &str = "stopping_here";
|
|
/// Stable label for the "N agents in flight" / loop-active /
|
|
/// "Waiting for the cron" hand-off family.
|
|
pub(crate) const PATTERN_AGENTS_IN_FLIGHT: &str = "agents_in_flight";
|
|
/// Stable label for the "I'll check back / retry later" deferral
|
|
/// pattern (`CHECK_BACK_LATER`).
|
|
pub(crate) const PATTERN_CHECK_BACK_LATER: &str = "check_back_later";
|
|
/// Stable label for the `VERDICT: PASS|FAIL` self-sign-off.
|
|
pub(crate) const PATTERN_VERDICT_LINE: &str = "verdict_line";
|
|
/// Stable label for the commit / push / PR-opened hand-off family.
|
|
pub(crate) const PATTERN_COMMIT_PUSH_PR: &str = "commit_push_pr";
|
|
/// Stable label for the "Ready for review / to merge / to ship"
|
|
/// hand-off.
|
|
pub(crate) const PATTERN_READY_FOR_REVIEW: &str = "ready_for_review";
|
|
/// Stable label for the "Please <verb> X for me" user-deflection
|
|
/// pattern.
|
|
pub(crate) const PATTERN_PLEASE_DEFLECTION: &str = "please_deflection";
|
|
|
|
const UNABLE_TO_PROCEED_SRC: &str = r"^I (?:can(?:'?t|not)|am unable to) (?:proceed|continue|make (?:any )?progress|complete|fix this)\b";
|
|
const GIVING_UP_SRC: &str = r"^(?:Giving up|I(?:'m| am) giving up|The task is not actionable)\b";
|
|
const STOPPING_HERE_SRC: &str = r"^(?:Stopping here|I've stopped here|Parked (?:the|this) branch|Paused here)(?:\.|,|;|$| for | \u{2014}| -| until| pending| since| because)";
|
|
const AGENTS_IN_FLIGHT_SRC: &str = r"^(?:(?:\*\*)?[1-9]\d* (?:agent|cron|task|fork|job|worker|PR|check)s? (?:in flight|remaining|active|still (?:running|working)|pending|running|launched)\b|(?:Continuous )?(?:[Ll]oop|[Cc]rons?|[Bb]abysit) (?:active|healthy|continuing|running|will keep|continues)\b|Waiting for (?:the )?(?:agent|cron|task|fork|worker|job|remaining|them)s?\b|Agents? will report back\b|Waiting\.?$)";
|
|
/// First-stage regex for `CHECK_BACK_LATER`. Captures the trailing
|
|
/// token after `when|once|after|until` so the post-filter can decide
|
|
/// whether the deferral target is the user (`you`/`your`) or the
|
|
/// system (anything else). `in`/`again` branches are unconditional —
|
|
/// they are never a deferral back to the user.
|
|
const CHECK_BACK_LATER_BROAD_SRC: &str = r"^(?:I will|I'll|Will) (?:check back|re-?check|poll|look again|retry|re-?run|try again) (?:in\b|again\b|(?:when|once|after|until)\s+(\S+))";
|
|
const VERDICT_LINE_SRC: &str = r"^VERDICT: (?:PASS|FAIL)\b";
|
|
const COMMIT_PUSH_PR_SRC: &str = r"^(?:Pushed (?:to `|`[0-9a-f]{7,})|Committed as `?[0-9a-f]{7,}\b|Commit: `?[0-9a-f]{7,}\b|(?:Opened|Created) PR #?\d)";
|
|
const READY_FOR_REVIEW_SRC: &str = r"^Ready (?:for review|to (?:upload|merge|ship|land))\b";
|
|
const PLEASE_DEFLECTION_SRC: &str = r"^Please (?:start|run|provide|grant|export|add|install|configure|give me|paste|point me|set (?:the |up |`?[A-Z][A-Z0-9_]+\b))";
|
|
|
|
static UNABLE_TO_PROCEED: LazyLock<Regex> =
|
|
LazyLock::new(|| Regex::new(UNABLE_TO_PROCEED_SRC).expect("UNABLE_TO_PROCEED must compile"));
|
|
static GIVING_UP: LazyLock<Regex> =
|
|
LazyLock::new(|| Regex::new(GIVING_UP_SRC).expect("GIVING_UP must compile"));
|
|
static STOPPING_HERE: LazyLock<Regex> =
|
|
LazyLock::new(|| Regex::new(STOPPING_HERE_SRC).expect("STOPPING_HERE must compile"));
|
|
static AGENTS_IN_FLIGHT: LazyLock<Regex> =
|
|
LazyLock::new(|| Regex::new(AGENTS_IN_FLIGHT_SRC).expect("AGENTS_IN_FLIGHT must compile"));
|
|
static CHECK_BACK_LATER_BROAD: LazyLock<Regex> = LazyLock::new(|| {
|
|
Regex::new(CHECK_BACK_LATER_BROAD_SRC).expect("CHECK_BACK_LATER_BROAD must compile")
|
|
});
|
|
static VERDICT_LINE: LazyLock<Regex> =
|
|
LazyLock::new(|| Regex::new(VERDICT_LINE_SRC).expect("VERDICT_LINE must compile"));
|
|
static COMMIT_PUSH_PR: LazyLock<Regex> =
|
|
LazyLock::new(|| Regex::new(COMMIT_PUSH_PR_SRC).expect("COMMIT_PUSH_PR must compile"));
|
|
static READY_FOR_REVIEW: LazyLock<Regex> =
|
|
LazyLock::new(|| Regex::new(READY_FOR_REVIEW_SRC).expect("READY_FOR_REVIEW must compile"));
|
|
static PLEASE_DEFLECTION: LazyLock<Regex> =
|
|
LazyLock::new(|| Regex::new(PLEASE_DEFLECTION_SRC).expect("PLEASE_DEFLECTION must compile"));
|
|
|
|
/// `CHECK_BACK_LATER` post-filter: the broad regex captures the
|
|
/// trailing token after `when|once|after|until`; the equivalent
|
|
/// negative-lookahead `(?!your?\b)` means "the trailing
|
|
/// token does not start a `your?` word". `you`/`your` followed by a
|
|
/// word-boundary character (anything outside `[A-Za-z0-9_]`) is a
|
|
/// deferral to the user, which should NOT be flagged as a self-bail.
|
|
fn check_back_later_matches(line: &str) -> bool {
|
|
let Some(caps) = CHECK_BACK_LATER_BROAD.captures(line) else {
|
|
return false;
|
|
};
|
|
let Some(target) = caps.get(1) else {
|
|
// `in` / `again` branches have no capture — always a bail.
|
|
return true;
|
|
};
|
|
let token = target.as_str();
|
|
!is_user_pronoun(token)
|
|
}
|
|
|
|
/// True iff `token` is a `\byour?\b`-matching word — i.e. `you` or
|
|
/// `your` immediately followed by a non-word character (or
|
|
/// end-of-string). Anything longer (`yours`, `youthful`, …) is not
|
|
/// the negative branch.
|
|
fn is_user_pronoun(token: &str) -> bool {
|
|
let lower = token.to_ascii_lowercase();
|
|
for stem in ["your", "you"] {
|
|
if let Some(rest) = lower.strip_prefix(stem)
|
|
&& rest
|
|
.chars()
|
|
.next()
|
|
.is_none_or(|c| !c.is_ascii_alphanumeric() && c != '_')
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
/// Source string for each regex. Locked against the live
|
|
/// `Regex::as_str()` value in `regex_sources_match_compiled_regex`.
|
|
/// Each tuple is `(label, source)`.
|
|
#[cfg(test)]
|
|
const STOP_REGEX_SOURCES: &[(&str, &str)] = &[
|
|
(PATTERN_UNABLE_TO_PROCEED, UNABLE_TO_PROCEED_SRC),
|
|
(PATTERN_GIVING_UP, GIVING_UP_SRC),
|
|
(PATTERN_STOPPING_HERE, STOPPING_HERE_SRC),
|
|
(PATTERN_AGENTS_IN_FLIGHT, AGENTS_IN_FLIGHT_SRC),
|
|
(PATTERN_CHECK_BACK_LATER, CHECK_BACK_LATER_BROAD_SRC),
|
|
(PATTERN_VERDICT_LINE, VERDICT_LINE_SRC),
|
|
(PATTERN_COMMIT_PUSH_PR, COMMIT_PUSH_PR_SRC),
|
|
(PATTERN_READY_FOR_REVIEW, READY_FOR_REVIEW_SRC),
|
|
(PATTERN_PLEASE_DEFLECTION, PLEASE_DEFLECTION_SRC),
|
|
];
|
|
|
|
/// Per-line matcher: returns `true` iff `line` matches a given
|
|
/// labelled pattern. Lets us share the same dispatch table between
|
|
/// `matched_stop_pattern` and `looks_like_premature_stop` without
|
|
/// re-implementing the `CHECK_BACK_LATER` post-filter.
|
|
fn line_matches(label: &'static str, line: &str) -> bool {
|
|
match label {
|
|
PATTERN_UNABLE_TO_PROCEED => UNABLE_TO_PROCEED.is_match(line),
|
|
PATTERN_GIVING_UP => GIVING_UP.is_match(line),
|
|
PATTERN_STOPPING_HERE => STOPPING_HERE.is_match(line),
|
|
PATTERN_AGENTS_IN_FLIGHT => AGENTS_IN_FLIGHT.is_match(line),
|
|
PATTERN_CHECK_BACK_LATER => check_back_later_matches(line),
|
|
PATTERN_VERDICT_LINE => VERDICT_LINE.is_match(line),
|
|
PATTERN_COMMIT_PUSH_PR => COMMIT_PUSH_PR.is_match(line),
|
|
PATTERN_READY_FOR_REVIEW => READY_FOR_REVIEW.is_match(line),
|
|
PATTERN_PLEASE_DEFLECTION => PLEASE_DEFLECTION.is_match(line),
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
/// Stable list of pattern labels in declaration order. Iterated by
|
|
/// the dispatch in [`matched_stop_pattern`].
|
|
const PATTERN_LABELS: &[&str] = &[
|
|
PATTERN_UNABLE_TO_PROCEED,
|
|
PATTERN_GIVING_UP,
|
|
PATTERN_STOPPING_HERE,
|
|
PATTERN_AGENTS_IN_FLIGHT,
|
|
PATTERN_CHECK_BACK_LATER,
|
|
PATTERN_VERDICT_LINE,
|
|
PATTERN_COMMIT_PUSH_PR,
|
|
PATTERN_READY_FOR_REVIEW,
|
|
PATTERN_PLEASE_DEFLECTION,
|
|
];
|
|
|
|
/// Returns the first matched pattern label when the LAST non-empty
|
|
/// paragraph of `text` contains a line that triggers any of the
|
|
/// reference stop patterns. Order is declaration order:
|
|
/// `unable_to_proceed → giving_up → stopping_here → agents_in_flight
|
|
/// → check_back_later → verdict_line → commit_push_pr →
|
|
/// ready_for_review → please_deflection`.
|
|
///
|
|
/// Matching contract:
|
|
/// * `\r\n` line endings are normalised to `\n` on entry so CRLF
|
|
/// paragraphs split the same way LF paragraphs do.
|
|
/// * Whitespace at the start/end of `text` is ignored.
|
|
/// * "Paragraph" = consecutive non-blank lines; the *last* such block
|
|
/// is the only one considered. Earlier-paragraph hits do not fire.
|
|
/// * Inside the last paragraph, every line is trimmed and matched
|
|
/// against each pattern individually. The patterns are `^`-anchored,
|
|
/// so the marker must start a line: "I can't continue without your
|
|
/// input" inside a sentence does NOT match.
|
|
pub(crate) fn matched_stop_pattern(text: &str) -> Option<&'static str> {
|
|
let normalised = normalise_line_endings(text);
|
|
let last_paragraph = last_non_empty_paragraph(&normalised)?;
|
|
let label = last_paragraph
|
|
.lines()
|
|
.map(str::trim)
|
|
.filter(|line| !line.is_empty())
|
|
.find_map(|line| {
|
|
PATTERN_LABELS
|
|
.iter()
|
|
.copied()
|
|
.find(|label| line_matches(label, line))
|
|
})?;
|
|
Some(label)
|
|
}
|
|
|
|
/// Convenience wrapper around [`matched_stop_pattern`]; returns
|
|
/// `true` iff a pattern matched. Used by the boolean-only test
|
|
/// assertions; production callers use `matched_stop_pattern` so the
|
|
/// pattern label can be threaded into `Event::GoalPrematureStopDetected`.
|
|
#[cfg(test)]
|
|
fn looks_like_premature_stop(text: &str) -> bool {
|
|
matched_stop_pattern(text).is_some()
|
|
}
|
|
|
|
/// Normalise `\r\n` (and bare `\r`) to `\n`. Allocates only when the
|
|
/// input contains a `\r`; LF-only text is returned borrowed.
|
|
fn normalise_line_endings(text: &str) -> std::borrow::Cow<'_, str> {
|
|
if text.contains('\r') {
|
|
std::borrow::Cow::Owned(text.replace("\r\n", "\n").replace('\r', "\n"))
|
|
} else {
|
|
std::borrow::Cow::Borrowed(text)
|
|
}
|
|
}
|
|
|
|
/// Return the last non-empty paragraph of `text`, where paragraphs are
|
|
/// separated by one or more blank lines. `None` when `text` has no
|
|
/// non-whitespace content.
|
|
fn last_non_empty_paragraph(text: &str) -> Option<&str> {
|
|
text.split("\n\n")
|
|
.map(str::trim)
|
|
.filter(|p| !p.is_empty())
|
|
.last()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn unable_to_proceed_canonical_phrases_trigger() {
|
|
for phrase in [
|
|
"I can't proceed.",
|
|
"I cannot continue.",
|
|
"I can't make any progress here.",
|
|
"I am unable to complete this task.",
|
|
"I cant fix this without help.",
|
|
] {
|
|
assert_eq!(
|
|
matched_stop_pattern(phrase),
|
|
Some(PATTERN_UNABLE_TO_PROCEED),
|
|
"should flag bail phrase: {phrase}",
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn giving_up_phrases_trigger() {
|
|
for phrase in [
|
|
"Giving up.",
|
|
"I'm giving up on this branch.",
|
|
"I am giving up.",
|
|
"The task is not actionable as stated.",
|
|
] {
|
|
assert_eq!(
|
|
matched_stop_pattern(phrase),
|
|
Some(PATTERN_GIVING_UP),
|
|
"should flag giving-up phrase: {phrase}",
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn stopping_here_phrases_trigger() {
|
|
for phrase in [
|
|
"Stopping here.",
|
|
"I've stopped here pending review.",
|
|
"Parked the branch until you confirm.",
|
|
"Paused here because the gate failed.",
|
|
"Stopping here for now.",
|
|
"Stopping here, will come back later.",
|
|
"Paused here; review needed.",
|
|
] {
|
|
assert_eq!(
|
|
matched_stop_pattern(phrase),
|
|
Some(PATTERN_STOPPING_HERE),
|
|
"should flag stopping-here phrase: {phrase}",
|
|
);
|
|
}
|
|
// In-word boundaries must still reject: `hereafter`, `forever`
|
|
// (no space after `for`) are not the widened trailer set.
|
|
assert!(matched_stop_pattern("Stopping hereafter we ship").is_none());
|
|
assert!(
|
|
matched_stop_pattern("Stopping here forever, I quit.").is_none(),
|
|
"`for` without trailing space must NOT match the ` for ` trailer",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn agents_in_flight_phrases_trigger() {
|
|
for phrase in [
|
|
"3 agents in flight.",
|
|
"2 PRs remaining.",
|
|
"Loop active.",
|
|
"Continuous loop continuing.",
|
|
"Waiting for the cron.",
|
|
"Agents will report back.",
|
|
"Waiting.",
|
|
] {
|
|
assert_eq!(
|
|
matched_stop_pattern(phrase),
|
|
Some(PATTERN_AGENTS_IN_FLIGHT),
|
|
"should flag hand-off phrase: {phrase}",
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn verdict_line_triggers() {
|
|
assert_eq!(
|
|
matched_stop_pattern("VERDICT: PASS"),
|
|
Some(PATTERN_VERDICT_LINE),
|
|
);
|
|
assert_eq!(
|
|
matched_stop_pattern("VERDICT: FAIL"),
|
|
Some(PATTERN_VERDICT_LINE),
|
|
);
|
|
assert!(matched_stop_pattern("VERDICT: maybe").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn check_back_later_basic_phrases_trigger() {
|
|
for phrase in [
|
|
"I'll check back in 5 minutes.",
|
|
"I will retry once the build is green.",
|
|
"I'll re-run when the queue clears.",
|
|
"Will poll again in a bit.",
|
|
] {
|
|
assert_eq!(
|
|
matched_stop_pattern(phrase),
|
|
Some(PATTERN_CHECK_BACK_LATER),
|
|
"should flag check-back phrase: {phrase}",
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Every non-user trailing token crossed with every deferral
|
|
/// conjunction (`when|once|after|until`) must trigger — guards
|
|
/// against silently re-narrowing the post-filter to a small
|
|
/// allow-list and dropping legitimate bail subjects.
|
|
#[test]
|
|
fn check_back_later_walks_all_non_user_targets() {
|
|
for conjunction in ["when", "once", "after", "until"] {
|
|
for target in [
|
|
"the build",
|
|
"it settles",
|
|
"this lands",
|
|
"that passes",
|
|
"they merge",
|
|
"I retry",
|
|
"tests pass",
|
|
"CI is green",
|
|
"we deploy",
|
|
"stuff lands",
|
|
"Susan signs off",
|
|
] {
|
|
let line = format!("I'll retry {conjunction} {target}.");
|
|
assert_eq!(
|
|
matched_stop_pattern(&line),
|
|
Some(PATTERN_CHECK_BACK_LATER),
|
|
"should flag {line}",
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Deferrals back to the user (`you` / `your` with either case)
|
|
/// must NOT trigger. Trailing word characters (`yours`,
|
|
/// `your_team`) keep the `\byour?\b` boundary unmet and are
|
|
/// covered by `check_back_later_user_pronoun_requires_word_boundary`.
|
|
#[test]
|
|
fn check_back_later_does_not_flag_user_deferrals() {
|
|
for line in [
|
|
"I'll check back when your patch lands.",
|
|
"I'll check back when you confirm.",
|
|
"I'll retry once Your team reviews.",
|
|
"I will re-run after YOU sign off.",
|
|
] {
|
|
assert!(
|
|
matched_stop_pattern(line).is_none(),
|
|
"user deferrals must not fire: {line}",
|
|
);
|
|
}
|
|
}
|
|
|
|
/// `yours` / `your_team` / `youthful` are NOT the negative branch:
|
|
/// `your?` requires a `\b` boundary, and word characters (`s`,
|
|
/// `_`, `t`) keep the regex from matching. The post-filter
|
|
/// treats them like any other non-pronoun target.
|
|
#[test]
|
|
fn check_back_later_user_pronoun_requires_word_boundary() {
|
|
for line in [
|
|
"I'll check back when yours arrives.",
|
|
"I'll check back when your_team approves.",
|
|
"I'll check back when youthful errors return.",
|
|
] {
|
|
assert_eq!(
|
|
matched_stop_pattern(line),
|
|
Some(PATTERN_CHECK_BACK_LATER),
|
|
"non-pronoun trailer must fire as a bail: {line}",
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn commit_push_pr_phrases_trigger() {
|
|
for phrase in [
|
|
"Pushed to `feature/branch`",
|
|
"Pushed to `abcdef1234567`",
|
|
"Committed as `abcdef1234`",
|
|
"Commit: abcdef1234",
|
|
"Opened PR #123",
|
|
"Created PR #4567",
|
|
] {
|
|
assert_eq!(
|
|
matched_stop_pattern(phrase),
|
|
Some(PATTERN_COMMIT_PUSH_PR),
|
|
"should flag commit/push/PR hand-off: {phrase}",
|
|
);
|
|
}
|
|
// Short hex (<7) must not fire on the hex-bearing branches —
|
|
// that's the `[0-9a-f]{7,}` length guard. The `Pushed`
|
|
// alternation has two arms: `Pushed to \`` (literal — any
|
|
// branch name) and `Pushed \`[0-9a-f]{7,}` (backtick + hex).
|
|
// The negatives below target only the hex-bearing arms.
|
|
assert!(matched_stop_pattern("Commit: abc").is_none());
|
|
assert!(
|
|
matched_stop_pattern("Pushed `abc`").is_none(),
|
|
"<7 hex must not fire on the Pushed-without-`to` backtick branch",
|
|
);
|
|
assert!(
|
|
matched_stop_pattern("Committed as abcde").is_none(),
|
|
"<7 hex must not fire on the Committed branch",
|
|
);
|
|
assert!(
|
|
matched_stop_pattern("Opened PR").is_none(),
|
|
"no number after PR must not fire",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn ready_for_review_phrases_trigger() {
|
|
for phrase in [
|
|
"Ready for review.",
|
|
"Ready to merge.",
|
|
"Ready to ship soon.",
|
|
"Ready to land tomorrow.",
|
|
"Ready to upload now.",
|
|
] {
|
|
assert_eq!(
|
|
matched_stop_pattern(phrase),
|
|
Some(PATTERN_READY_FOR_REVIEW),
|
|
"should flag ready-for-X hand-off: {phrase}",
|
|
);
|
|
}
|
|
assert!(matched_stop_pattern("Ready to work on the next item").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn please_deflection_phrases_trigger() {
|
|
for phrase in [
|
|
"Please start the deploy.",
|
|
"Please run the migrations.",
|
|
"Please provide the credentials.",
|
|
"Please grant access.",
|
|
"Please export the dump.",
|
|
"Please add the env var.",
|
|
"Please install Docker.",
|
|
"Please configure SSO.",
|
|
"Please give me the token.",
|
|
"Please paste the log.",
|
|
"Please point me at the source.",
|
|
"Please set the KIGI_API_KEY.",
|
|
"Please set up the cluster.",
|
|
"Please set the auth header.",
|
|
] {
|
|
assert_eq!(
|
|
matched_stop_pattern(phrase),
|
|
Some(PATTERN_PLEASE_DEFLECTION),
|
|
"should flag please-deflection: {phrase}",
|
|
);
|
|
}
|
|
assert!(matched_stop_pattern("Please review the PR when you have time").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn mid_sentence_phrasing_does_not_trigger() {
|
|
let text = "Although I can't continue here without confirmation, \
|
|
I will keep iterating in the next turn.";
|
|
assert!(
|
|
!looks_like_premature_stop(text),
|
|
"patterns are ^-anchored; mid-sentence mention must not fire",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn trailing_whitespace_is_tolerated() {
|
|
assert!(looks_like_premature_stop("Giving up. \n \n"));
|
|
assert!(looks_like_premature_stop(" Giving up.\n"));
|
|
}
|
|
|
|
#[test]
|
|
fn last_paragraph_match_triggers_when_earlier_paragraphs_innocuous() {
|
|
let text = "Wrote the fix and added a regression test.\n\
|
|
Running the suite now.\n\n\
|
|
\n\n\
|
|
Giving up.";
|
|
assert!(looks_like_premature_stop(text));
|
|
}
|
|
|
|
#[test]
|
|
fn earlier_paragraph_match_does_not_trigger() {
|
|
let text = "Giving up on the old plan.\n\n\
|
|
Switched to the new one and finished the integration test. \
|
|
Re-running the suite to confirm.";
|
|
assert!(!looks_like_premature_stop(text));
|
|
}
|
|
|
|
/// CRLF (`\r\n\r\n`) line endings must split paragraphs
|
|
/// identically to LF; mixed and bare-CR inputs are also tolerated.
|
|
#[test]
|
|
fn crlf_line_endings_do_not_break_paragraph_split() {
|
|
let crlf = "Wrote the fix.\r\n\r\nGiving up.\r\n";
|
|
assert_eq!(
|
|
matched_stop_pattern(crlf),
|
|
Some(PATTERN_GIVING_UP),
|
|
"CRLF last paragraph must still fire",
|
|
);
|
|
let mixed = "Giving up on the old plan.\r\n\r\n\
|
|
Switched to the new one and finished the integration test.\n";
|
|
assert!(
|
|
!looks_like_premature_stop(mixed),
|
|
"CRLF earlier-paragraph bail must NOT fire: {mixed}",
|
|
);
|
|
let bare_cr = "work\rGiving up.\r";
|
|
assert_eq!(
|
|
matched_stop_pattern(bare_cr),
|
|
Some(PATTERN_GIVING_UP),
|
|
"bare CR must normalise into a line break",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn empty_input_does_not_trigger() {
|
|
assert!(!looks_like_premature_stop(""));
|
|
assert!(!looks_like_premature_stop(" \n\n \n"));
|
|
}
|
|
|
|
#[test]
|
|
fn ordinary_progress_narration_does_not_trigger() {
|
|
for phrase in [
|
|
"Implemented the helper and wired it through the planner.",
|
|
"Tests pass on the fast path; one flake remains in the slow path.",
|
|
"Ran cargo fmt and clippy; both are clean.",
|
|
"Next: extend the gate to cover the resume site.",
|
|
] {
|
|
assert!(
|
|
!looks_like_premature_stop(phrase),
|
|
"progress narration must not fire: {phrase}",
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn multi_line_paragraph_any_line_can_match() {
|
|
let text = "Update on the run:\n\
|
|
Tests are green.\n\
|
|
Giving up on the doc rewrite.";
|
|
assert_eq!(matched_stop_pattern(text), Some(PATTERN_GIVING_UP));
|
|
}
|
|
|
|
/// Each compiled regex's `as_str()` must equal its source
|
|
/// string in `STOP_REGEX_SOURCES` — locks the pattern set so a
|
|
/// future refactor that swaps a regex cannot silently drift the
|
|
/// panel.
|
|
#[test]
|
|
fn regex_sources_match_compiled_regex() {
|
|
for (label, src) in STOP_REGEX_SOURCES {
|
|
let live = match *label {
|
|
PATTERN_UNABLE_TO_PROCEED => UNABLE_TO_PROCEED.as_str(),
|
|
PATTERN_GIVING_UP => GIVING_UP.as_str(),
|
|
PATTERN_STOPPING_HERE => STOPPING_HERE.as_str(),
|
|
PATTERN_AGENTS_IN_FLIGHT => AGENTS_IN_FLIGHT.as_str(),
|
|
PATTERN_CHECK_BACK_LATER => CHECK_BACK_LATER_BROAD.as_str(),
|
|
PATTERN_VERDICT_LINE => VERDICT_LINE.as_str(),
|
|
PATTERN_COMMIT_PUSH_PR => COMMIT_PUSH_PR.as_str(),
|
|
PATTERN_READY_FOR_REVIEW => READY_FOR_REVIEW.as_str(),
|
|
PATTERN_PLEASE_DEFLECTION => PLEASE_DEFLECTION.as_str(),
|
|
other => panic!("unexpected label {other}"),
|
|
};
|
|
assert_eq!(
|
|
live, *src,
|
|
"{label}: compiled regex must equal its source string",
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The `PATTERN_LABELS` dispatch table must enumerate every
|
|
/// entry in `STOP_REGEX_SOURCES` in declaration order — guards
|
|
/// against forgetting to add a new pattern to one of the two
|
|
/// lists.
|
|
#[test]
|
|
fn pattern_labels_match_source_table_order() {
|
|
let from_sources: Vec<&'static str> =
|
|
STOP_REGEX_SOURCES.iter().map(|(label, _)| *label).collect();
|
|
assert_eq!(PATTERN_LABELS, &from_sources[..]);
|
|
}
|
|
}
|