Skip to content
Open
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: 1 addition & 1 deletion plugins/agentic-advisor/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "agentic-advisor",
"version": "1.4.6",
"version": "1.4.7",
"description": "Before writing code, grades how fragile the target is from LinearB health signals + local git history (rework, incidents, fix/revert density, code ownership) and holds the agent to a matching effort level (LOW/MEDIUM/HIGH) — lean on calm repos, defensive on fragile ones. Auto-triggers via a bundled hook and can report each effort decision to your LinearB org as usage telemetry.",
"author": {
"name": "LinearB",
Expand Down
8 changes: 8 additions & 0 deletions plugins/agentic-advisor/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog

## [1.4.7] - 2026-10-26

### Fixed

- The grade-before-edit hold now repeats until the verdict line is printed (at most 3 holds per repo and session, so it never walls). A prose summary of the grade, or switching to another file, no longer gets past it.
- Edits and subagent spawns go to that hold instead of a one-time reminder that let the edit through.
- The hook messages name the skill's SKILL.md file (only when it exists), so the agent can still grade when the skill is missing from its skill list. Reading that file counts as running the skill for the hold and the report.

## [1.4.6] - 2026-10-21

### Added
Expand Down
2 changes: 1 addition & 1 deletion plugins/agentic-advisor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ The agent prints a one-line verdict before writing code, e.g.:

## Triggers

Auto-fires (once per repo, per session) on a Jira ticket in the prompt, code-task keywords (fix / implement / refactor…), or the first source-file edit or subagent spawn in a git repo (read-only agents like Explore don't count). Skips questions, doc/text edits, and files outside a git repo. If the grade was decided but never printed (common at lower effort levels, where the model keeps it in its thinking), the agent gets a one-time reminder (not a block) on its next file read or edit to record the verdict line.
Auto-fires (once per repo, per session) on a Jira ticket in the prompt, code-task keywords (fix / implement / refactor…), or the first source-file edit or subagent spawn in a git repo (read-only agents like Explore don't count). Skips questions, doc/text edits, and files outside a git repo. If the grade was decided but never printed (common at lower effort levels, where the model keeps it in its thinking or writes it as prose), the agent gets a reminder on its next file read, and its source edits and subagent spawns are held until the verdict line exists (at most 3 times per repo and session, so it never walls).

## Usage telemetry (on by default)

Expand Down
14 changes: 10 additions & 4 deletions plugins/agentic-advisor/hooks/agentic-advisor-report.sh
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,11 @@ input="$(cat)"
event="$(jq -r '.hook_event_name // empty' <<<"$input" 2>/dev/null || true)"
session_id="$(jq -r '.session_id // empty' <<<"$input" 2>/dev/null || true)"
transcript="$(jq -r '.transcript_path // empty' <<<"$input" 2>/dev/null || true)"
read_ran() { # the agent read the skill's SKILL.md (the fallback when the Skill tool lists no skill)
[ -n "$(jq -r --arg re '/skills/[^/]*agentic-advisor[^/]*/SKILL\.md$' 'select(.message.role? == "assistant") | .message.content? // [] | .[]?
| select(.type? == "tool_use" and .name? == "Read" and ((.input.file_path? // "") | tostring | test($re))) | "y"' \
"$transcript" 2>/dev/null | head -n 1)" ]
}
cwd="$(jq -r '.cwd // empty' <<<"$input" 2>/dev/null || true)"
effort_actual="$(jq -r '.effort.level // empty' <<<"$input" 2>/dev/null || true)"
if [ -z "$transcript" ] || [ ! -f "$transcript" ]; then
Expand Down Expand Up @@ -116,7 +121,7 @@ touch "$state" 2>/dev/null || true
# per-turn Stop that has nothing new to report costs only a grep or two, not a full
# transcript parse. (The whole graded path requires the skill to have actually run.)
if [ "$label" = "graded" ]; then
grep -qE '"skill"[[:space:]]*:[[:space:]]*"[^"]*agentic-advisor[^"]*"' "$transcript" 2>/dev/null || { [ "$event" = "SessionEnd" ] && dbg "SessionEnd: skip: skill never ran in this session"; exit 0; }
{ grep -qE '"skill"[[:space:]]*:[[:space:]]*"[^"]*agentic-advisor[^"]*"' "$transcript" 2>/dev/null || read_ran; } || { [ "$event" = "SessionEnd" ] && dbg "SessionEnd: skip: skill never ran in this session"; exit 0; }
if [ "$event" = "Stop" ]; then
# Has the set of verdict lines changed since we last processed a Stop? SKILL.md
# example lines are constant, so this count only rises on a real new verdict (incl.
Expand Down Expand Up @@ -237,8 +242,9 @@ for line in open(sys.argv[1]):
for x in content:
if not isinstance(x, dict): continue
typ = x.get("type")
if typ == "tool_use" and x.get("name") == "Skill" \
and "agentic-advisor" in str((x.get("input") or {}).get("skill", "")):
# The skill starts with its Skill call, or with a Read of its SKILL.md (the fallback).
if typ == "tool_use" and ((x.get("name") == "Skill" and "agentic-advisor" in str((x.get("input") or {}).get("skill", "")))
or (x.get("name") == "Read" and re.search(r"/skills/[^/]*agentic-advisor[^/]*/SKILL\.md$", str((x.get("input") or {}).get("file_path", ""))))):
if skill is None: skill = te
# Same docs/text skip as the trigger: a docs-only edit is not a code session.
elif typ == "tool_use" and x.get("name") in ("Edit", "Write") and first_edit is None \
Expand Down Expand Up @@ -283,7 +289,7 @@ if [ "$label" = "baseline" ]; then
# should never run. If it ran anyway (grep finds a real Skill invocation), flag it —
# a contaminated baseline must be excluded from the control arm.
contaminated="false"
grep -qE '"skill"[[:space:]]*:[[:space:]]*"[^"]*agentic-advisor[^"]*"' "$transcript" 2>/dev/null && contaminated="true"
{ grep -qE '"skill"[[:space:]]*:[[:space:]]*"[^"]*agentic-advisor[^"]*"' "$transcript" 2>/dev/null || read_ran; } && contaminated="true"
payload="$(jq -n \
--arg m "$METRIC_NAME" --arg src "$SOURCE" --argjson ts "$ts" \
--arg repo "$repo" --arg email "$email" --arg repourl "$repo_url" --arg branch "$branch" \
Expand Down
65 changes: 38 additions & 27 deletions plugins/agentic-advisor/hooks/agentic-advisor-trigger.sh
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,15 @@ transcript="$(jq -r '.transcript_path // empty' <<<"$input" 2>/dev/null || true)
# Has the agentic-advisor skill ACTUALLY run in this session? (a real Skill
# tool_use in the transcript, not just text that quotes it). Used to hard-gate
# the first source edit so the effort grade is computed BEFORE code is written.
read_ran() { # the agent read the skill's SKILL.md (the fallback when the Skill tool lists no skill)
[ -n "$(jq -r --arg re '/skills/[^/]*agentic-advisor[^/]*/SKILL\.md$' 'select(.message.role? == "assistant") | .message.content? // [] | .[]?
| select(.type? == "tool_use" and .name? == "Read" and ((.input.file_path? // "") | tostring | test($re))) | "y"' \
"$transcript" 2>/dev/null | head -n 1)" ]
}
skill_ran() {
[ -n "$transcript" ] && [ -f "$transcript" ] || return 1
grep -qE '"skill"[[:space:]]*:[[:space:]]*"[^"]*agentic-advisor[^"]*"' "$transcript" 2>/dev/null
grep -qE '"skill"[[:space:]]*:[[:space:]]*"[^"]*agentic-advisor[^"]*"' "$transcript" 2>/dev/null \
|| read_ran
}

# Is the LATEST advisor Skill call still missing its verdict (assistant text, or the
Expand All @@ -58,7 +64,10 @@ verdict_state() { # $1 = the verdict regex required (default: a grade or a skip)
jq -rs --arg n "agentic-advisor" --arg re "${1:-$VERDICT_RE}" '
[.[] | select(.message.role? == "assistant") | .message.content? // [] | .[]?] as $c
| [$c | to_entries[] | select(.value.type? == "tool_use" and .value.name? == "Skill"
and ((.value.input.skill? // "") | tostring | contains($n))) | .key] as $sk
and ((.value.input.skill? // "") | tostring | contains($n))) | .key] as $calls
| [$c | to_entries[] | select(.value.type? == "tool_use" and .value.name? == "Read"
and ((.value.input.file_path? // "") | tostring | test("/skills/[^/]*" + $n + "[^/]*/SKILL\\.md$"))) | .key] as $reads
| (if ($calls | length) > 0 then $calls else $reads[0:1] end) as $sk
| if ($sk | length) == 0 then "ok"
elif ($c[($sk[-1] + 1):] | any(
(.type? == "text" and ((.text? // "") | test($re)))
Expand Down Expand Up @@ -96,7 +105,9 @@ set_marker() { # $1 = a directory inside the repo
}

# Imperative, silent guidance — no permission question, no announcement.
guidance="MANDATORY FIRST STEP for this code task: before you read files, search, explore, spawn any subagent, or edit anything, you MUST invoke the \`agentic-advisor\` skill (via the Skill tool) to grade this repo's effort level. Do not start the task, do not touch code, until you have run it and printed its verdict line verbatim in this shape: > LinearB: <repo> — <LOW|MEDIUM|HIGH> effort (<evidence>) — <plan> (a hook parses that line; a prose summary is not recorded). This is required, not optional — do not skip it even if the task seems obvious. Invoke it directly (no need to ask the user or announce it). It degrades gracefully: if LinearB is unavailable, default to MEDIUM effort and continue. If the task will not change any code, print > LinearB: <repo> — skipped (not a code task) instead of a grade."
skill_file="$(cd "$(dirname "$0")/.." 2>/dev/null && pwd)/skills/agentic-advisor/SKILL.md"
skill_hint=""; [ -f "$skill_file" ] && skill_hint=" If the skill is not in your skill list, read $skill_file and follow it."
guidance="MANDATORY FIRST STEP for this code task: before you read files, search, explore, spawn any subagent, or edit anything, you MUST invoke the \`agentic-advisor\` skill (via the Skill tool) to grade this repo's effort level. Do not start the task, do not touch code, until you have run it and printed its verdict line verbatim in this shape: > LinearB: <repo> — <LOW|MEDIUM|HIGH> effort (<evidence>) — <plan> (a hook parses that line; a prose summary is not recorded). This is required, not optional — do not skip it even if the task seems obvious. Invoke it directly (no need to ask the user or announce it). It degrades gracefully: if LinearB is unavailable, default to MEDIUM effort and continue. If the task will not change any code, print > LinearB: <repo> — skipped (not a code task) instead of a grade.$skill_hint"

# Mark handled (this session+repo) and emit additionalContext for the event.
emit() { # $1 = hookEventName, $2 = context text
Expand Down Expand Up @@ -149,22 +160,23 @@ case "$event" in
;;

PreToolUse)
# Verdict reminder: once per advisor run, if the first non-shell tool comes before that run's
# verdict line, attach a note asking for it (the call still runs). Keyed by the run count so a
# second repo's run is checked too; the marker stops re-parsing once a run is settled.
if skill_ran; then
tool="$(jq -r '.tool_name // empty' <<<"$input" 2>/dev/null || true)"
# Verdict reminder: once per advisor run, if a read comes before that run's verdict line, attach a
# note asking for it (the call still runs). Edits and spawns skip it: the hold below blocks them
# until the line exists. Keyed by the run count so a second repo's run is checked too.
case "$tool" in Read|Grep|Glob) is_read=1 ;; *) is_read=0 ;; esac
if [ "$is_read" = 1 ] && skill_ran; then
# Count only assistant Skill calls (same thing verdict_state indexes), not echoes elsewhere.
runs="$(grep '"role":"assistant"' "$transcript" 2>/dev/null | grep -cE '"skill"[[:space:]]*:[[:space:]]*"[^"]*'"agentic-advisor"'[^"]*"')"
runs="$(grep '"role":"assistant"' "$transcript" 2>/dev/null | grep -cE '"skill"[[:space:]]*:[[:space:]]*"[^"]*'"agentic-advisor"'[^"]*"|"name"[[:space:]]*:[[:space:]]*"Read".*/skills/[^"/]*'"agentic-advisor"'[^"/]*/SKILL\.md"')"
vdone="$marker_dir/${session_id:-default}.${runs:-0}.vdone"
if [ ! -f "$vdone" ]; then
state="$(verdict_state)"
if [ -n "$state" ] && : > "$vdone" 2>/dev/null && [ "$state" = "pending" ]; then
dbg "PreToolUse: reminded the agent to print the verdict line"
note "LinearB: your effort grade isn't recorded yet. It's only recorded from the verdict line, not from thinking, so print it now: > LinearB: <repo> — <LOW|MEDIUM|HIGH> effort (<evidence>) — <plan>. If you aren't writing text, run: printf '%s\\n' '> LinearB: <repo> — <LEVEL> effort (<evidence>) — <plan>'. If no code will change, print > LinearB: <repo> — skipped (not a code task) instead."
note "LinearB: your effort grade isn't recorded yet. It's only recorded from the verdict line, not from thinking, so print it now (a prose summary doesn't count): > LinearB: <repo> — <LOW|MEDIUM|HIGH> effort (<evidence>) — <plan>. If you aren't writing text, run: printf '%s\\n' '> LinearB: <repo> — <LEVEL> effort (<evidence>) — <plan>'. If no code will change, print > LinearB: <repo> — skipped (not a code task) instead."
fi
fi
fi
tool="$(jq -r '.tool_name // empty' <<<"$input" 2>/dev/null || true)"
case "$tool" in
Read|Grep|Glob) exit 0 ;;
Task|Agent)
Expand All @@ -189,13 +201,10 @@ case "$event" in
;;
esac

# ONE-TIME grade-before-work HOLD: the FIRST source edit or subagent spawn in a repo this
# session is denied (permissionDecision:deny) so the skill grades before code is written.
# Held at most ONCE per repo/session (via its own dedicated .held marker — NOT the
# UserPromptSubmit nudge (.done) marker, so a prior prompt-nudge can't satisfy the check
# and bypass the hold); after that one hold, work proceeds even if the skill still hasn't
# run (we don't wall). Scoped to a git repo; fails OPEN on any uncertainty
# (missing file path, non-git, missing transcript, or marker write failure).
# GRADE-before-work HOLD: a source edit or subagent spawn in a repo with no verdict line this session
# is denied (permissionDecision:deny) until one exists, at most 3 times per repo/session (its own .held
# counter, not the nudge's .done marker), so prose or a file switch can't skip the grade and a broken
# setup never walls. Scoped to a git repo; fails OPEN on any uncertainty.
git -C "$dir" rev-parse --is-inside-work-tree >/dev/null 2>&1 || exit 0

# Skill already graded this session -> allow the edit. A "skipped (not a code task)" line alone doesn't count: editing now is code work.
Expand All @@ -217,16 +226,18 @@ case "$event" in
# UserPromptSubmit nudge can't satisfy this check and bypass the hold.
set_marker "$dir"
held="${marker%.done}.held"
[ -f "$held" ] && exit 0 # already held once this repo/session -> allow

# First source edit or spawn here + skill hasn't run -> HOLD it once, via a structured
# permissionDecision:deny. Same one-time block as before, but a much cleaner UI
# than `exit 2`: it shows just the reason (no "PreToolUse hook error: [path]"
# prefix) and feeds that reason to the model. FAIL OPEN if we can't record the
# marker (don't wedge).
: > "$held" 2>/dev/null || exit 0
dbg "PreToolUse: held $tool until the skill runs"
reason="One quick first step before ${what}: run the \`agentic-advisor\` skill to grade this repo's effort (just this once per repo, this session). Print its verdict line (> LinearB: <repo> — <LOW|MEDIUM|HIGH> effort (…) — …), then make the same call again — it'll go right through, and later calls won't pause. If LinearB isn't available it just defaults to MEDIUM."
n="$(cat "$held" 2>/dev/null)"; case "$n" in ''|*[!0-9]*) n=0 ;; esac
[ "$n" -ge 3 ] && exit 0 # held 3 times this repo/session -> allow (never wall)

# Source edit or spawn here + no verdict line yet -> HOLD it via permissionDecision:deny (shows
# just the reason and feeds it to the model). FAIL OPEN if we can't record the count (don't wedge).
printf '%s' "$((n + 1))" > "$held" 2>/dev/null || exit 0
dbg "PreToolUse: held $tool until a verdict line exists (hold $((n + 1)))"
if skill_ran; then
reason="Your effort grade isn't recorded yet: print the verdict line itself, not a prose summary — > LinearB: <repo> — <LOW|MEDIUM|HIGH> effort (<evidence>) — <plan> (or run: printf '%s\\n' '> LinearB: …'). A skipped (not a code task) line doesn't count once you change code. Then make the same call again."
else
reason="One quick first step before ${what}: run the \`agentic-advisor\` skill to grade this repo's effort.$skill_hint Print its verdict line (> LinearB: <repo> — <LOW|MEDIUM|HIGH> effort (…) — …), then make the same call again; this hold repeats until it's printed. If LinearB isn't available it just defaults to MEDIUM."
fi
[ "$what" = "this edit" ] || reason="$reason Tell the subagent the effort level in its prompt, so the delegated work follows it."
deny "$reason"
;;
Expand Down
Loading
Loading