Skip to content

feat(hooks): complete governance hook-event coverage — context injection + subagent/compact/failure/notification observability (#255)#258

Open
richard-devbot wants to merge 8 commits into
mainfrom
feat/hook-event-coverage-255
Open

feat(hooks): complete governance hook-event coverage — context injection + subagent/compact/failure/notification observability (#255)#258
richard-devbot wants to merge 8 commits into
mainfrom
feat/hook-event-coverage-255

Conversation

@richard-devbot

@richard-devbot richard-devbot commented Jul 9, 2026

Copy link
Copy Markdown
Owner

Closes #255. First slice of the full hook-parity wave (ref suite: 14-event production hooks). RStack now covers every governance-relevant Claude Code hook event, and Tau's real event surface, with a coherent CLI (guard / observe / context / notify-hook) instead of loose scripts.

Commits (bisected):

  1. rstack-agents context — UserPromptSubmit + SessionStart inject a ≤1KB RStack-awareness packet (active run + stage, pending approvals/decisions counts, orchestrator pointer) via hookSpecificOutput.additionalContext. Closes the Pi-only "orchestrator packet injection" gap: any harness agent now knows the governed state. No active run → emits NOTHING (no malformed context). Built only from RStack-generated ids/counts — never echoes prompt text, tool inputs, or decision text.
  2. observe extended — SubagentStart/Stop → subagent_started/stopped, PreCompact → context_preserved, PostToolUseFailure → error tool_result. Same contract: always exit 0, no-op without a run, redaction + truncation reused.
  3. rstack-agents notify-hook — Notification events relay to configured channels via notifyAll (no channels → pre-network no-op; fire-and-forget, bounded).
  4. init: full CLAUDE_CODE_HOOKS map (11 event entries; guard + existing observe intact; idempotent, never edits an existing settings.json).
  5. Tau: wired tool_execution_failure, before_compaction, and context injection on before_agent_start (timeout-bounded, "" on failure); verified against installed Tau — no subagent/notification events exist there (documented honestly).
  6. doctor + docs: wiring checks + the full hook map in claude-code.md/HARNESS.md/tau.md.

Session-safety: every new verb ALWAYS exits 0 (only guard may exit 2); double-wrapped try/catch; 1MB stdin caps; ≤1KB injection; Tau context hard-timeout 5s → "".

Gates: typecheck, 881/881 tests (+31), lint, validate (196), security-audit, diff-check, Tau AST — all green.

Adversarial review runs on this PR before merge (session-embedded surface).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added new CLI commands for context injection and notification handling.
    • Expanded observability to capture subagent activity, context preservation, and additional tool/session events.
    • Broadened setup and integration guidance to cover the full hook set.
  • Bug Fixes

    • Improved hook behavior so non-critical commands never interrupt sessions.
    • Added best-effort redaction and truncation to reduce accidental secret exposure.
  • Documentation

    • Updated CLI reference and integration docs with the latest hook mappings and expected behavior.

richardsongunde and others added 8 commits July 9, 2026 09:29
…tack-awareness (#255)

New framework-neutral context injector for UserPromptSubmit + SessionStart hooks.
Reads the hook JSON on stdin, resolves the active run, and emits a small
structural packet — active run id + current stage, count of pending approvals +
open decisions, and a static orchestrator pointer — in Claude Code's
{"hookSpecificOutput":{...,"additionalContext":"..."}} shape on stdout.

Contract mirrors observe: ALWAYS exit 0 (context hooks can't deny), never
throws, injects nothing (no stdout) when there is no active run, and never
injects secrets — the packet is built only from ids/stage/integer counts we
generate, never from prompt text, tool inputs, or decision question text, so
there is no channel for a credential to reach the model. Injected string is
capped at ~1KB. Wired into bin/rstack-agents.js.

Closes the Pi-only "orchestrator packet injection" gap for every harness.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… events (#255)

Extend normalizeObservation's hook_event_name vocabulary (same exit-0 / no-op /
redaction contract):

  - SubagentStart  -> subagent_started { agent_type }
  - SubagentStop   -> subagent_stopped { agent_type }  (distinct from
    session_shutdown — a delegated builder/validator finishing is its own signal;
    SubagentStop previously collapsed to session_shutdown)
  - PreCompact     -> context_preserved { trigger }
  - PostToolUseFailure -> tool_result { isError:true } (defaults to error even
    without an explicit is_error flag; checked before the tool_response inference
    so it isn't short-circuited)

agent_type/trigger are secret-redacted and length-bounded like every other
value. Existing tool_call/tool_result/session_shutdown mappings unchanged. The
dashboard filters by known types, so the new events append to the ledger without
disturbing any rollup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New framework-neutral relay for the host Notification hook: reads the payload on
stdin (Claude Code {message,title}) or --message/--title flags and forwards it to
every configured RStack channel via the existing notifications/router.js
(notifyAll — Slack/Teams/Discord/Telegram/WhatsApp).

Contract: ALWAYS exit 0, never throws, silent no-op when no channels are
configured (no parse, no network), and secret-redacted + truncated — it forwards
only the host's short message/title, never tool inputs or file contents. This is
the one hook that makes a network call; notifyAll is fire-and-forget with
per-channel error capture and a bounded timeout, so a slow/failing webhook can
never disrupt the session. Wired into bin/rstack-agents.js.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…#255)

Extend the Claude Code hook set init installs (idempotent; guard + existing
observe entries intact):

  - SessionStart: hub launcher + `rstack-agents context` (two hooks)
  - UserPromptSubmit: `rstack-agents context`
  - PostToolUseFailure / SubagentStart / SubagentStop / PreCompact:
    `rstack-agents observe`
  - Notification: `rstack-agents notify-hook`

PreToolUse (guard) is the only hook that can block, unchanged. Updated the
report labels + next-steps, the .claude/rstack-sdlc.md doc (context /
observability / notifications sections), and the pinned-shape test to assert
every event → command mapping.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…#255)

Register the Tau events that map to the new coverage:

  - before_compaction        -> context_preserved { trigger } (fire-and-forget)
  - tool_execution_failure   -> error tool_result (fire-and-forget)
  - before_agent_start       -> context injection: fetch the RStack packet via
    `rstack-agents context` and prepend it to the turn's system prompt
    (BeforeAgentStartEventResult). Best-effort, hard-timeout-bounded, returns no
    override on any failure — can never block a turn.

Documented the two events Tau does NOT expose: no delegated-SUBAGENT lifecycle
(agent_start/end are the per-prompt loop, not spawned specialists) and no
NOTIFICATION event (Tau surfaces messages via its own TUI). Verified event names
and result types against the Tau hooks package. Existing tool_call/tool_result
wiring unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extend the wiring checks:
  - claude-code: new "context hook" (UserPromptSubmit/SessionStart → context) and
    "notification hook" (Notification → notify-hook) checks. WARN not FAIL —
    additive, like the observe check.
  - tau: new "tau context hook" check (before_agent_start → context injection)
    alongside the existing observability check.

All new checks carry a fix/hint. Added doctor tests for the full claude-code hook
set and the tau context wiring.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- docs/integrations/claude-code.md: full hook-map table (every event → command →
  what it does / what it never does), plus Context injection and Notifications
  sections; updated the settings.json block, the "Pi-only" note (context is no
  longer Pi-only), and the observe event vocabulary.
- docs/HARNESS.md: new "Hook system" section — the four verbs, their contract
  (only guard blocks; all others exit 0 / no-op / redact), the observe event
  vocabulary, the context packet's structural-only guarantee, the notify relay,
  and the Tau coverage + the two events Tau lacks.
- docs/integrations/tau.md: observability/context/notifications section.
- README.md: guard/observe/context/notify-hook CLI rows + the Claude Code hook
  rows in the hooks table.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
--source is still accepted on the CLI for hook-command symmetry, but the context
packet describes the run, not the harness, so it isn't consumed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@qodo-code-review

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds context and notify-hook CLI commands for Claude Code hook governance, expands observe event mapping for subagent/precompact/failure events, wires new hooks into init scaffolding, doctor checks, and the Tau adapter, and updates documentation and tests across the suite.

Changes

Full hook-event coverage

Layer / File(s) Summary
Context injection command
src/commands/context.js, bin/rstack-agents.js, tests/context-cli.test.js
New context command builds a bounded run-state packet (run id, stage, approval/decision counts, orchestrator pointer), reads stdin, and always exits 0; wired as a CLI subcommand and covered by unit/e2e tests.
Notification relay command
src/commands/notify-hook.js, bin/rstack-agents.js, tests/notify-hook.test.js
New notify-hook command parses/redacts message+title, builds a Slack-style payload, relays via notifyAll to configured channels, no-ops silently otherwise, and always exits 0; wired as a CLI subcommand with tests.
Observe event mapping expansion
src/commands/observe.js, tests/observe-cli.test.js
normalizeObservation now maps SubagentStart/Stop, PreCompact, and PostToolUseFailure to subagent_started/stopped, context_preserved, and tool_result(isError:true) events, with agent_type/trigger fields.
Claude Code init wiring
src/integrations/init.js, tests/integrations-init.test.js
CLAUDE_CODE_HOOKS wires SessionStart/UserPromptSubmit to context, additional lifecycle hooks to observe, and Notification to notify-hook; docs and next-steps text updated.
Doctor checks
src/commands/doctor.js, tests/doctor.test.js
Adds context-hook and notification-hook checks for Claude Code, and a context-hook check for the Tau adapter.
Tau adapter wiring
src/integrations/tau/rstack_sdlc.py
Adds tool_execution_failure, before_compaction, and before_agent_start hooks, plus a _fetch_context helper that shells out to rstack-agents context.
Documentation updates
README.md, docs/HARNESS.md, docs/integrations/claude-code.md, docs/integrations/tau.md
Documents the full guard/observe/context/notify-hook CLI contract, hook maps, context/notification behavior across harnesses.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related issues

Poem

A rabbit hops through hooks anew,
Context, notify, and observe too 🐇
No throws, no blocks, just exit-0 grace,
Secrets tucked away, safe in their place.
Subagents whisper, compaction hums,
The dashboard glows — the whole harness thrums!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 23.08% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately reflects the main scope: expanded governance hook coverage with context injection and new observability events.
Linked Issues check ✅ Passed The PR adds context, expands observe coverage, wires init/Tau, updates doctor, and documents/tests the new hook contract as requested by #255.
Out of Scope Changes check ✅ Passed The changes stay focused on hook coverage, wiring, docs, and tests, with no obvious unrelated or extraneous functionality added.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/hook-event-coverage-255

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (3)
src/commands/context.js (1)

172-183: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract readStdinText to a shared utility.

This function and the MAX_STDIN_BYTES constant are duplicated identically in src/commands/notify-hook.js. Extracting to a shared module (e.g., src/utils/stdin.js) eliminates the copy and ensures the cap stays consistent across commands.

♻️ Suggested extraction
+// src/utils/stdin.js
+export const MAX_STDIN_BYTES = 1_000_000;
+
+export async function readStdinText(stream = process.stdin) {
+  if (stream.isTTY) return '';
+  let data = '';
+  stream.setEncoding('utf8');
+  for await (const chunk of stream) {
+    data += chunk;
+    if (data.length >= MAX_STDIN_BYTES) return data.slice(0, MAX_STDIN_BYTES);
+  }
+  return data;
+}

Then in both context.js and notify-hook.js:

-import { readStdinText } from './utils/stdin.js';
+// remove the local readStdinText + MAX_STDIN_BYTES definitions
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/commands/context.js` around lines 172 - 183, The readStdinText helper and
MAX_STDIN_BYTES cap are duplicated in context.js and notify-hook.js, so move
them into a shared utility module and have both commands import the same
implementation. Create a reusable stdin helper (for example, a shared utility
for readStdinText and the cap constant) and update the existing readStdinText
references in context.js and notify-hook.js to use it so the behavior stays
consistent in one place.
src/integrations/init.js (1)

295-346: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider extracting GUARD_CMD and HUB_CMD constants for consistency.

OBSERVE_CMD, CONTEXT_CMD, and NOTIFY_CMD are extracted as named constants, but the hub command (line 325, 'npx -y rstack-agents hub') and guard command (line 331, 'npx --yes rstack-agents guard --context builder') are inline strings. Extracting them would improve consistency and make future command changes a single-point edit. The -y vs --yes difference between hub and all other commands is intentional (tested at line 181 of the test file), but consolidating to --yes would reduce visual noise.

♻️ Optional refactor
 const OBSERVE_CMD = 'npx --yes rstack-agents observe --source claude-code';
 const CONTEXT_CMD = 'npx --yes rstack-agents context --source claude-code';
 const NOTIFY_CMD = 'npx --yes rstack-agents notify-hook --source claude-code';
+const HUB_CMD = 'npx -y rstack-agents hub';
+const GUARD_CMD = 'npx --yes rstack-agents guard --context builder';

 export const CLAUDE_CODE_HOOKS = Object.freeze({
   hooks: {
     SessionStart: [
-      { hooks: [{ type: 'command', command: 'npx -y rstack-agents hub' }] },
+      { hooks: [{ type: 'command', command: HUB_CMD }] },
       { hooks: [{ type: 'command', command: CONTEXT_CMD }] },
     ],
     UserPromptSubmit: [{ hooks: [{ type: 'command', command: CONTEXT_CMD }] }],
     PreToolUse: [{
       matcher: 'Bash|Write|Edit',
-      hooks: [{ type: 'command', command: 'npx --yes rstack-agents guard --context builder' }],
+      hooks: [{ type: 'command', command: GUARD_CMD }],
     }],
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/integrations/init.js` around lines 295 - 346, The inline hub and guard
commands in CLAUDE_CODE_HOOKS are inconsistent with the existing extracted
command constants, making future edits harder. Add named constants like HUB_CMD
and GUARD_CMD alongside OBSERVE_CMD, CONTEXT_CMD, and NOTIFY_CMD, then replace
the inline strings in SessionStart and PreToolUse with those constants. Keep the
current hub invocation flag style intact unless you intend to update the related
test coverage too.
tests/observe-cli.test.js (1)

296-303: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Add a trigger redaction test for parity with agent_type.

Both agent_type and trigger pass through redactSecrets in normalizeObservation (src/commands/observe.js:209,218), but only agent_type redaction is tested end-to-end. A PreCompact payload with a secret-looking trigger would close the coverage gap.

🛡️ Suggested additional test
 test('observe: a secret-looking agent_type / trigger is redacted', async () => {
   const { root, runDir } = seedProject();
   await runObserve(['--source', 'claude-code', '--project', root],
     { input: JSON.stringify({ hook_event_name: 'SubagentStart', agent_type: 'token=AKIAIOSFODNN7EXAMPLE123' }) });
   const raw = readFileSync(join(runDir, 'events.jsonl'), 'utf8');
   assert.ok(!raw.includes('AKIAIOSFODNN7EXAMPLE123'), 'secret in agent_type redacted');
+
+  const { root: root2, runDir: runDir2 } = seedProject();
+  await runObserve(['--source', 'claude-code', '--project', root2],
+    { input: JSON.stringify({ hook_event_name: 'PreCompact', trigger: 'token=AKIAIOSFODNN7EXAMPLE456' }) });
+  const raw2 = readFileSync(join(runDir2, 'events.jsonl'), 'utf8');
+  assert.ok(!raw2.includes('AKIAIOSFODNN7EXAMPLE456'), 'secret in trigger redacted');
 });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/observe-cli.test.js` around lines 296 - 303, Add an end-to-end observe
CLI test for trigger redaction to match the existing agent_type coverage. Extend
the tests in observe-cli.test.js by adding a case that sends a PreCompact
payload with a secret-looking trigger through runObserve, then assert the
recorded events.jsonl does not contain the secret value. Use
normalizeObservation and redactSecrets as the relevant behavior being exercised,
and keep the new test alongside the existing secret-redaction test for
agent_type.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/commands/notify-hook.js`:
- Around line 84-87: The notification payload and verbose error handling are
leaking unredacted strings outside the process. Update buildNotifyPayload and
the related catch/verbose logging paths in notify-hook.js so every outbound
string goes through the same redaction/truncation flow before being returned,
logged, or printed, including source and any raw error messages. Use the
existing notify-hook helpers and symbols around buildNotifyPayload and the catch
path to ensure no verbatim external text can escape.
- Around line 120-121: The notify-hook fan-out result currently always returns
reason as relayed even when every sender fails, which leads to misleading
output. Update the return logic in notifyAll/notify-hook so the reason reflects
success only when at least one sender succeeds, and uses a failure-specific
reason when results.some((r) => r.ok) is false. Use the existing notifyAll call
and the returned results array to determine both notified and reason
consistently.

In `@src/integrations/tau/rstack_sdlc.py`:
- Around line 424-455: The timeout handling in _fetch_context leaves the
subprocess alive when asyncio.wait_for cancels communicate(), so update the
subprocess lifecycle there to explicitly terminate or kill proc on timeout
before returning "". Use the existing async subprocess flow in _fetch_context,
and make sure any timeout or exception path cleans up the npx/node process so
repeated before_agent_start calls do not accumulate orphaned processes.
- Line 76: Use the camelCase field when constructing the
BeforeAgentStartEventResult in rstack_sdlc so the RStack context is actually
passed through; replace the current system_prompt usage with systemPrompt in the
code path that builds the result object, and verify the event payload still
includes the expected ToolCallEventResult handling.

In `@tests/notify-hook.test.js`:
- Around line 44-52: The no-channel notify-hook tests are using a shared temp
root, so stale config can leak into `hasConfiguredChannels({ projectRoot })` and
make the assertions flaky. Update the `notify-hook` test setup to create an
isolated temporary project root per test case instead of passing `tmpdir()`
directly, and use that per-test root when calling `runCli` for the no-channel
and malformed-stdin cases. Keep the tests’ expectations the same, but ensure
each test starts from a clean `projectRoot` so `hasConfiguredChannels` cannot
see unrelated config.

---

Nitpick comments:
In `@src/commands/context.js`:
- Around line 172-183: The readStdinText helper and MAX_STDIN_BYTES cap are
duplicated in context.js and notify-hook.js, so move them into a shared utility
module and have both commands import the same implementation. Create a reusable
stdin helper (for example, a shared utility for readStdinText and the cap
constant) and update the existing readStdinText references in context.js and
notify-hook.js to use it so the behavior stays consistent in one place.

In `@src/integrations/init.js`:
- Around line 295-346: The inline hub and guard commands in CLAUDE_CODE_HOOKS
are inconsistent with the existing extracted command constants, making future
edits harder. Add named constants like HUB_CMD and GUARD_CMD alongside
OBSERVE_CMD, CONTEXT_CMD, and NOTIFY_CMD, then replace the inline strings in
SessionStart and PreToolUse with those constants. Keep the current hub
invocation flag style intact unless you intend to update the related test
coverage too.

In `@tests/observe-cli.test.js`:
- Around line 296-303: Add an end-to-end observe CLI test for trigger redaction
to match the existing agent_type coverage. Extend the tests in
observe-cli.test.js by adding a case that sends a PreCompact payload with a
secret-looking trigger through runObserve, then assert the recorded events.jsonl
does not contain the secret value. Use normalizeObservation and redactSecrets as
the relevant behavior being exercised, and keep the new test alongside the
existing secret-redaction test for agent_type.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 6a11ed15-4a70-4d4b-af48-830833fd4642

📥 Commits

Reviewing files that changed from the base of the PR and between c14714c and b71dfbf.

📒 Files selected for processing (16)
  • README.md
  • bin/rstack-agents.js
  • docs/HARNESS.md
  • docs/integrations/claude-code.md
  • docs/integrations/tau.md
  • src/commands/context.js
  • src/commands/doctor.js
  • src/commands/notify-hook.js
  • src/commands/observe.js
  • src/integrations/init.js
  • src/integrations/tau/rstack_sdlc.py
  • tests/context-cli.test.js
  • tests/doctor.test.js
  • tests/integrations-init.test.js
  • tests/notify-hook.test.js
  • tests/observe-cli.test.js

Comment on lines +84 to +87
export function buildNotifyPayload({ message, title }, source) {
const header = title ? `*RStack · ${title}*` : '*RStack notification*';
const src = source ? ` _(${source})_` : '';
return { text: `${header}${src}\n${message}` };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Redact every string that can leave the process.

source is relayed verbatim, and the catch path can print raw error messages under --verbose. Both bypass the redaction/truncation contract.

Proposed fix
 export function buildNotifyPayload({ message, title }, source) {
-  const header = title ? `*RStack · ${title}*` : '*RStack notification*';
-  const src = source ? ` _(${source})_` : '';
-  return { text: `${header}${src}\n${message}` };
+  const headerTitle = title ? safeText(title, 120) : null;
+  const header = headerTitle ? `*RStack · ${headerTitle}*` : '*RStack notification*';
+  const safeSource = source ? safeText(source, 120) : '';
+  const src = safeSource ? ` _(${safeSource})_` : '';
+  return { text: `${header}${src}\n${safeText(message)}` };
 }
...
   } catch (error) {
-    return { notified: false, reason: `notify failed (ignored): ${error?.message ?? error}` };
+    return { notified: false, reason: `notify failed (ignored): ${safeText(error?.message ?? error, 200)}` };
   }
 }

Also applies to: 122-123, 154-155

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/commands/notify-hook.js` around lines 84 - 87, The notification payload
and verbose error handling are leaking unredacted strings outside the process.
Update buildNotifyPayload and the related catch/verbose logging paths in
notify-hook.js so every outbound string goes through the same
redaction/truncation flow before being returned, logged, or printed, including
source and any raw error messages. Use the existing notify-hook helpers and
symbols around buildNotifyPayload and the catch path to ensure no verbatim
external text can escape.

Comment on lines +120 to +121
const results = await notifyAll(payload, { projectRoot, env, ...(senders ? { senders } : {}) });
return { notified: results.some((r) => r.ok), reason: 'relayed', results };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Avoid reporting failed fan-out as relayed.

When every sender fails, notified is false but reason is still 'relayed', producing confusing verbose output like skipped: relayed.

Proposed fix
     const payload = buildNotifyPayload(notification, source);
     // notifyAll is already fire-and-forget with per-channel error capture and
     // never throws; we still guard the call.
     const results = await notifyAll(payload, { projectRoot, env, ...(senders ? { senders } : {}) });
-    return { notified: results.some((r) => r.ok), reason: 'relayed', results };
+    const notified = results.some((r) => r.ok);
+    return {
+      notified,
+      reason: notified ? 'relayed' : 'all configured notification channels failed',
+      results,
+    };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const results = await notifyAll(payload, { projectRoot, env, ...(senders ? { senders } : {}) });
return { notified: results.some((r) => r.ok), reason: 'relayed', results };
const results = await notifyAll(payload, { projectRoot, env, ...(senders ? { senders } : {}) });
const notified = results.some((r) => r.ok);
return {
notified,
reason: notified ? 'relayed' : 'all configured notification channels failed',
results,
};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/commands/notify-hook.js` around lines 120 - 121, The notify-hook fan-out
result currently always returns reason as relayed even when every sender fails,
which leads to misleading output. Update the return logic in
notifyAll/notify-hook so the reason reflects success only when at least one
sender succeeds, and uses a failure-specific reason when results.some((r) =>
r.ok) is false. Use the existing notifyAll call and the returned results array
to determine both notified and reason consistently.

from pydantic import BaseModel, Field

from tau.hooks import ToolCallEventResult
from tau.hooks import BeforeAgentStartEventResult, ToolCallEventResult

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify BeforeAgentStartEventResult is exported from tau.hooks and its constructor signature.

# Find the tau package installation
fd -t f 'hooks' $(python -c "import tau; print(tau.__path__[0])" 2>/dev/null) 2>/dev/null || \
  fd -t f -i 'hooks.py' $(python -c "import tau; print(tau.__path__[0])" 2>/dev/null) 2>/dev/null

# Search for the class definition and its __init__/constructor
rg -n 'class BeforeAgentStartEventResult' $(python -c "import tau; print(tau.__path__[0])" 2>/dev/null)
rg -n 'system_prompt' $(python -c "import tau; print(tau.__path__[0])" 2>/dev/null) -g '*.py'

Repository: richard-devbot/SDLC-rstack

Length of output: 231


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the integration file and nearby Tau hook usage.
git ls-files src/integrations/tau/rstack_sdlc.py
wc -l src/integrations/tau/rstack_sdlc.py
cat -n src/integrations/tau/rstack_sdlc.py | sed -n '1,220p'

echo "---- search for before_agent_start / BeforeAgentStartEventResult ----"
rg -n "before_agent_start|BeforeAgentStartEventResult|ToolCallEventResult|system_prompt" src docs .

Repository: richard-devbot/SDLC-rstack

Length of output: 19352


🌐 Web query:

Tau before_agent_start hook BeforeAgentStartEventResult system_prompt tau.hooks

💡 Result:

The before_agent_start hook is an event within agent-based frameworks—notably those inspired by or related to the Pi coding agent architecture (such as OpenClaw and various Pi-derived agents)—that executes after a user submits a prompt but before the agent loop begins [1][2][3][4]. Purpose and Functionality: The primary purpose of this hook is to allow plugins or extensions to inspect, append, or modify the context (including system prompts) before the LLM processes the turn [5][6][7]. Key Characteristics: - Event Timing: It triggers after user input is received and potentially expanded, but before the agent starts executing its core loop (LLM calls, tool execution) [2][8][3]. - System Prompt Interaction: Extensions can typically access the current system prompt through the event and return a replacement or modified system prompt [6][8][7]. If multiple extensions register handlers for this hook, the system prompt is often chained across them [8][9][7]. - Context Injection: Beyond modifying the system prompt, this hook is frequently used to inject persistent messages into the session history, which are often visible in terminal user interfaces (TUI) [2][6][3]. Technical Implementation: - Event Data (BeforeAgentStartEvent): Typically includes the user's prompt text, any attached images, and the current system prompt string [8][9][3]. - Result Type (BeforeAgentStartEventResult): Usually defines optional fields such as: - message: A custom message to be injected into the session context [6][8][3]. - systemPrompt: A string used to replace or update the system prompt for the current turn [6][8][7]. While some modern implementations (such as OpenClaw) encourage using more granular hooks like before_model_resolve or before_prompt_build for specific tasks, before_agent_start is often maintained for legacy compatibility or as a convenient combined-phase hook [10][1]. Note on Terminology: There are multiple distinct projects referred to as Tau (e.g., dpc's Unix-native coding agent, the Hugging Face Tau coding agent, and others) [11][4][12]. The specific terminology before_agent_start and BeforeAgentStartEventResult is most strongly associated with the Pi-derived coding agent ecosystem, which has influenced several of these projects [2][7][11].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "---- tau integration docs ----"
wc -l docs/integrations/tau.md docs/HARNESS.md
sed -n '80,120p' docs/integrations/tau.md
echo "---- harness docs around tau ----"
sed -n '136,156p' docs/HARNESS.md

echo "---- tau dependency references ----"
rg -n '"tau"|tau[<=>~ ]|BeforeAgentStartEventResult|system_prompt|systemPrompt' pyproject.toml requirements*.txt package*.json uv.lock poetry.lock .

Repository: richard-devbot/SDLC-rstack

Length of output: 11965


🌐 Web query:

site:github.com/tau-agent/tau BeforeAgentStartEventResult system_prompt tau.hooks

💡 Result:

BeforeAgentStartEventResult uses systemPrompt (full replacement), not systemPromptAppend. The event also exposes systemPrompt and systemPromptOptions so handlers can inspect/chain the current prompt. [1][2]

If you want, I can also point to the exact tau.hooks file/path that defines it.


Use systemPrompt here BeforeAgentStartEventResult uses the camelCase field, so system_prompt will be ignored and the RStack context won’t reach the turn prompt.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/integrations/tau/rstack_sdlc.py` at line 76, Use the camelCase field when
constructing the BeforeAgentStartEventResult in rstack_sdlc so the RStack
context is actually passed through; replace the current system_prompt usage with
systemPrompt in the code path that builds the result object, and verify the
event payload still includes the expected ToolCallEventResult handling.

Comment on lines +424 to +455
async def _fetch_context(cwd: str) -> str:
"""Fetch the RStack context packet via `rstack-agents context` (#255).

Returns the `additionalContext` string, or "" when there is no active run,
no context, or anything goes wrong. Best-effort and hard-timeout-bounded:
this runs on the before_agent_start critical path, so it must never block a
turn — every failure path returns "" and the turn proceeds unchanged.
"""
npx = shutil.which("npx")
if npx is None:
return ""
try:
proc = await asyncio.create_subprocess_exec(
npx, "--yes", "rstack-agents", "context", "--source", "tau", "--project", cwd,
stdin=asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL,
env=os.environ, cwd=cwd,
)
out, _ = await asyncio.wait_for(proc.communicate(), timeout=5.0)
except Exception:
return "" # context is additive — never let it break or delay a turn
text = out.decode("utf-8", "replace").strip()
if not text:
return ""
try:
data = json.loads(text)
ctx = data.get("hookSpecificOutput", {}).get("additionalContext", "")
return str(ctx) if ctx else ""
except Exception:
return ""


Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Kill the subprocess on timeout to prevent orphaned process accumulation.

When asyncio.wait_for times out, communicate() is cancelled but proc is not killed. The subprocess lingers indefinitely. Since _fetch_context runs on the before_agent_start critical path (every turn), repeated timeouts — e.g., a slow first npx install — will accumulate orphaned npx/node processes and exhaust resources over a long session.

🔒 Proposed fix
 async def _fetch_context(cwd: str) -> str:
     """Fetch the RStack context packet via `rstack-agents context` (`#255`).

     Returns the `additionalContext` string, or "" when there is no active run,
     no context, or anything goes wrong. Best-effort and hard-timeout-bounded:
     this runs on the before_agent_start critical path, so it must never block a
     turn — every failure path returns "" and the turn proceeds unchanged.
     """
     npx = shutil.which("npx")
     if npx is None:
         return ""
+    proc = None
     try:
         proc = await asyncio.create_subprocess_exec(
             npx, "--yes", "rstack-agents", "context", "--source", "tau", "--project", cwd,
             stdin=asyncio.subprocess.DEVNULL,
             stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL,
             env=os.environ, cwd=cwd,
         )
         out, _ = await asyncio.wait_for(proc.communicate(), timeout=5.0)
     except Exception:
+        if proc is not None:
+            try:
+                proc.kill()
+            except ProcessLookupError:
+                pass
         return ""  # context is additive — never let it break or delay a turn
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async def _fetch_context(cwd: str) -> str:
"""Fetch the RStack context packet via `rstack-agents context` (#255).
Returns the `additionalContext` string, or "" when there is no active run,
no context, or anything goes wrong. Best-effort and hard-timeout-bounded:
this runs on the before_agent_start critical path, so it must never block a
turnevery failure path returns "" and the turn proceeds unchanged.
"""
npx = shutil.which("npx")
if npx is None:
return ""
try:
proc = await asyncio.create_subprocess_exec(
npx, "--yes", "rstack-agents", "context", "--source", "tau", "--project", cwd,
stdin=asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL,
env=os.environ, cwd=cwd,
)
out, _ = await asyncio.wait_for(proc.communicate(), timeout=5.0)
except Exception:
return "" # context is additive — never let it break or delay a turn
text = out.decode("utf-8", "replace").strip()
if not text:
return ""
try:
data = json.loads(text)
ctx = data.get("hookSpecificOutput", {}).get("additionalContext", "")
return str(ctx) if ctx else ""
except Exception:
return ""
async def _fetch_context(cwd: str) -> str:
"""Fetch the RStack context packet via `rstack-agents context` (`#255`).
Returns the `additionalContext` string, or "" when there is no active run,
no context, or anything goes wrong. Best-effort and hard-timeout-bounded:
this runs on the before_agent_start critical path, so it must never block a
turnevery failure path returns "" and the turn proceeds unchanged.
"""
npx = shutil.which("npx")
if npx is None:
return ""
proc = None
try:
proc = await asyncio.create_subprocess_exec(
npx, "--yes", "rstack-agents", "context", "--source", "tau", "--project", cwd,
stdin=asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL,
env=os.environ, cwd=cwd,
)
out, _ = await asyncio.wait_for(proc.communicate(), timeout=5.0)
except Exception:
if proc is not None:
try:
proc.kill()
except ProcessLookupError:
pass
return "" # context is additive — never let it break or delay a turn
text = out.decode("utf-8", "replace").strip()
if not text:
return ""
try:
data = json.loads(text)
ctx = data.get("hookSpecificOutput", {}).get("additionalContext", "")
return str(ctx) if ctx else ""
except Exception:
return ""
🧰 Tools
🪛 Ruff (0.15.20)

[warning] 443-443: Do not catch blind exception: Exception

(BLE001)


[warning] 452-452: Do not catch blind exception: Exception

(BLE001)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/integrations/tau/rstack_sdlc.py` around lines 424 - 455, The timeout
handling in _fetch_context leaves the subprocess alive when asyncio.wait_for
cancels communicate(), so update the subprocess lifecycle there to explicitly
terminate or kill proc on timeout before returning "". Use the existing async
subprocess flow in _fetch_context, and make sure any timeout or exception path
cleans up the npx/node process so repeated before_agent_start calls do not
accumulate orphaned processes.

Comment thread tests/notify-hook.test.js
Comment on lines +44 to +52
test('notify-hook CLI: no channels configured is a silent no-op, exit 0', async () => {
const payload = JSON.stringify({ hook_event_name: 'Notification', message: 'Claude needs your input' });
const { code, stdout } = await runCli(['--project', tmpdir()], { input: payload });
assert.equal(code, 0, 'always exits 0');
assert.equal(stdout.trim(), '', 'no output when there is nothing to relay');
});

test('notify-hook CLI: malformed stdin still exits 0 (never throws)', async () => {
const { code, stderr } = await runCli(['--project', tmpdir()], { input: 'not json }{' });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Use isolated temp project roots for no-channel tests.

Passing tmpdir() directly means hasConfiguredChannels({ projectRoot }) can see stale config under the shared system temp directory, making no-channel assertions environment-dependent.

Proposed fix
 import { spawn } from 'node:child_process';
+import { mkdtemp } from 'node:fs/promises';
 import { tmpdir } from 'node:os';
 import { dirname, resolve } from 'node:path';
...
 const BIN = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'rstack-agents.js');
+const tempProject = () => mkdtemp(resolve(tmpdir(), 'rstack-notify-'));
...
 test('notify-hook CLI: no channels configured is a silent no-op, exit 0', async () => {
   const payload = JSON.stringify({ hook_event_name: 'Notification', message: 'Claude needs your input' });
-  const { code, stdout } = await runCli(['--project', tmpdir()], { input: payload });
+  const { code, stdout } = await runCli(['--project', await tempProject()], { input: payload });
...
 test('notify-hook CLI: malformed stdin still exits 0 (never throws)', async () => {
-  const { code, stderr } = await runCli(['--project', tmpdir()], { input: 'not json }{' });
+  const { code, stderr } = await runCli(['--project', await tempProject()], { input: 'not json }{' });
...
   const result = await runNotifyHook({
-    stdinText: JSON.stringify({ message: 'hello' }), project: tmpdir(), env: cleanEnv(), senders,
+    stdinText: JSON.stringify({ message: 'hello' }), project: await tempProject(), env: cleanEnv(), senders,
...
   const result = await runNotifyHook({
-    stdinText: JSON.stringify({ message: 'hi' }), project: tmpdir(), env, senders,
+    stdinText: JSON.stringify({ message: 'hi' }), project: await tempProject(), env, senders,

Also applies to: 102-115

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/notify-hook.test.js` around lines 44 - 52, The no-channel notify-hook
tests are using a shared temp root, so stale config can leak into
`hasConfiguredChannels({ projectRoot })` and make the assertions flaky. Update
the `notify-hook` test setup to create an isolated temporary project root per
test case instead of passing `tmpdir()` directly, and use that per-test root
when calling `runCli` for the no-channel and malformed-stdin cases. Keep the
tests’ expectations the same, but ensure each test starts from a clean
`projectRoot` so `hasConfiguredChannels` cannot see unrelated config.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(hooks): complete governance hook-event coverage — context injection, subagent + precompact + notification + failure observability

2 participants